Preparación para la entrevista técnica
1 · Resumen ejecutivo
Qué pedía el test
Una mini-aplicación de gestión de usuarios con:
- Listado de usuarios con búsqueda y paginación de 10 en 10.
- Modificar usuario al pulsar sobre él (formulario en modal/página).
- Nuevo usuario mediante un botón en la cabecera.
Stack obligatorio: Bootstrap + jQuery + AJAX + PHP devolviendo JSON + MySQL. Todo testeado en XAMPP.
Qué entregaste
- Template SB Admin 2 (gratuito, Bootstrap 4) personalizado en
tables.html. - Cliente jQuery con AJAX en
js/usuarios.js(listar, paginar, buscar, crear, editar). - Backend PHP en
php/api.phpcon un único endpoint y parámetroaction. - BBDD MySQL
pruebatecnica· tablausers. - Validación de DNI español (8 dígitos + letra) en servidor.
- Validación de campos obligatorios y longitudes mínimas.
- Buscador en tiempo real sobre nombre, email, DNI y teléfono.
- Selector de avatar (4 fotos predefinidas) en los modales de crear/editar.
2 · Requisitos del PDF (palabra por palabra)
Tarea 1 · Listado de Usuarios
Listado con campos: DNI, nombre completo, fecha nacimiento, teléfono y email.
- Paginación (carga de 10 en 10).
- Buscador.
- Al seleccionar un usuario → abrir formulario de modificación.
- En la parte superior, botón "Nuevo Usuario".
Tarea 2 · Modificar Usuario
- Al pulsar un usuario → formulario con sus datos.
- Obligatorios: DNI, nombre completo y fecha de nacimiento.
- Tener en cuenta longitudes fijas: DNI, fecha y teléfono.
Tarea 3 · Nuevo Usuario
- Botón en el listado → formulario para introducir datos nuevos y guardar en BBDD.
- Mismas reglas de obligatoriedad y longitudes que en Modificar.
Metodología impuesta
- Front-end: Bootstrap (template a descargar del apartado Material).
- Back-end: AJAX con jQuery → llamada a PHP que devuelve JSON (estilo "API rest").
- Render: jQuery/JavaScript parsea el JSON y dibuja la información.
- BBDD: MySQL, tabla obligatoria
users. - Testing local: XAMPP (Apache + PHP + MySQL).
3 · Stack utilizado
El stack es el exacto que pedía la prueba. No hay framework moderno (React, Laravel...) porque el enunciado lo prohíbe implícitamente: pedía AJAX con jQuery directo contra PHP devolviendo JSON.
4 · Arquitectura de la solución
Patrón aplicado
- Cliente "SPA-light": una sola página HTML (
tables.html) que nunca se recarga; todos los cambios entran por AJAX. - Backend tipo "controlador único":
api.phprecibe un parámetroactiony enruta internamente. Es el equivalente "casero" a un router REST. - Formato de intercambio: JSON en ambas direcciones (request por
application/x-www-form-urlencodedal ser$.ajax POST, response conContent-Type: application/json).
5 · Esquema de base de datos
La tabla que da soporte a toda la aplicación:
CREATE DATABASE IF NOT EXISTS pruebatecnica
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE pruebatecnica;
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(150) NOT NULL,
dni VARCHAR(9) NOT NULL,
fecha_nacimiento DATE NOT NULL,
telefono VARCHAR(20) DEFAULT NULL,
email VARCHAR(150) DEFAULT NULL,
foto VARCHAR(100) DEFAULT 'undraw_profile.svg',
UNIQUE KEY uniq_dni (dni)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
VARCHAR(9) en DNI: porque el DNI español tiene
exactamente 8 dígitos + 1 letra = 9 caracteres. Es un campo de longitud fija, y al ponerlo
UNIQUE garantizamos a nivel de BBDD que no haya duplicados (defensa en profundidad).
CHAR(9) si es longitud fija? Respuesta:
la diferencia de rendimiento entre CHAR y VARCHAR para campos tan pequeños es
despreciable en InnoDB, y VARCHAR es más estándar. Si insistieran, CHAR(9) sería
marginalmente mejor.
6.1 · tables.html · estructura
Tomé el archivo tables.html original del template SB Admin 2 y reemplacé la tabla de ejemplo por la mía. Las piezas clave que añadí:
Buscador y botón "Nuevo Usuario"
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Listado de Usuarios</h1>
<button id="btnNuevoUsuario" class="btn btn-primary btn-icon-split">
<span class="icon text-white-50"><i class="fas fa-plus"></i></span>
<span class="text">Nuevo Usuario</span>
</button>
</div>
<div class="row mb-4">
<div class="col-12 col-md-6">
<input type="text" id="inputBuscar" class="form-control"
placeholder="Buscar por DNI, nombre, email o teléfono...">
</div>
</div>
Tabla vacía + contenedor de paginación
<table class="table table-bordered" id="tablaUsuarios">
<thead>
<tr>
<th>#</th><th>Nombre Completo</th><th>DNI</th>
<th>Fecha Nacimiento</th><th>Teléfono</th>
<th>Email</th><th>Acciones</th>
</tr>
</thead>
<tbody><!-- Las filas se cargan con AJAX --></tbody>
</table>
<div id="paginacion"></div>
Modales de Crear / Editar (mismo patrón)
- Bootstrap 4 modal con
data-toggle="modal". - Selector de avatar con
radio buttonsque envían el nombre del SVG. - Campos con
required,minlengthymaxlengthHTML5 (primera línea de defensa cliente). - Para editar, hay un
<input type="hidden" id="edit_id">que guarda el ID.
6.2 · js/usuarios.js · cliente jQuery
El archivo orquesta toda la interacción. Lo encapsulé en $(document).ready para evitar contaminar el ámbito global y para asegurarme de que el DOM existe.
Estado mínimo
let paginaActual = 1;
let terminoBusqueda = '';
Sólo dos variables porque la fuente de verdad es la base de datos. No mantengo el array de usuarios en memoria (cada cambio fuerza un re-fetch). Es simple y robusto a costa de algunas requests extras.
Función central: cargarUsuarios(page)
function cargarUsuarios(page = 1) {
paginaActual = page;
$.ajax({
url: 'php/api.php',
type: 'POST',
data: { action: 'listar', page: page, search: terminoBusqueda },
dataType: 'json',
success: function(respuesta) {
let filas = '';
respuesta.data.forEach(function(usuario, index) {
const numeroFila = (paginaActual - 1) * 10 + index + 1;
filas += `<tr>...</tr>`;
});
$('#tablaUsuarios tbody').html(filas);
generarPaginacion(respuesta.total_pages, paginaActual);
}
});
}
Event delegation
Los botones de la tabla y de la paginación se crean dinámicamente. Por eso enlazo eventos a document con delegación, no directamente al botón:
$(document).on('click', '.btn-editar', function() { ... });
$(document).on('click', '#btnAnterior', function() { ... });
$(document).on('click', '#btnSiguiente', function() { ... });
$('.btn-editar').click(...) directamente, sólo se enganchan los botones que existen
en ese instante. Cuando AJAX repinta la tabla, los nuevos botones no responden. Por eso engancho al
contenedor (document) y filtro con el selector. Esto se llama
delegación de eventos.
Buscador en tiempo real
$('#inputBuscar').on('keyup', function() {
terminoBusqueda = $(this).val().trim();
cargarUsuarios(1);
});
debounce de ~300 ms para esperar a que el usuario pare de teclear.
Lo verás en la sección 10.
6.3 · php/api.php · backend
Patrón front controller casero: un único archivo, un parámetro action, un if/else if que enruta a cada operación.
Cabecera y conexión
header('Content-Type: application/json; charset=utf-8');
$conn = new mysqli('localhost', 'root', '', 'pruebatecnica');
$conn->set_charset("utf8mb4");
$action = isset($_POST['action']) ? $_POST['action'] : '';
- UTF-8 tanto en cabecera como en la conexión (importante para tildes/ñ).
- mysqli en vez de PDO: válido para la prueba, pero PDO sería más portable.
Validación de DNI español
function esDniValido($dni) {
$dni = strtoupper(trim($dni));
if (strlen($dni) !== 9) return false;
if (!preg_match('/^[0-9]{8}[A-Z]$/', $dni)) return false;
return true;
}
TRWAGMYFPDXBNJZSQVHLCKE). La regex de arriba sólo valida el formato, no la letra real.
Una validación completa sería:
function letraDni(int $numero): string {
$letras = 'TRWAGMYFPDXBNJZSQVHLCKE';
return $letras[$numero % 23];
}
function esDniValido(string $dni): bool {
$dni = strtoupper(trim($dni));
if (!preg_match('/^(\d{8})([A-Z])$/', $dni, $m)) return false;
return letraDni((int)$m[1]) === $m[2];
}
Acción listar · paginación + búsqueda
$page = isset($_POST['page']) ? (int)$_POST['page'] : 1;
$limit = 10;
$offset = ($page - 1) * $limit;
$search = trim($_POST['search'] ?? '');
$where = '';
if ($search !== '') {
$searchEsc = $conn->real_escape_string($search);
$where = "WHERE nombre LIKE '%$searchEsc%' OR email LIKE '%$searchEsc%'
OR dni LIKE '%$searchEsc%' OR telefono LIKE '%$searchEsc%'";
}
$totalRegistros = $conn->query("SELECT COUNT(*) as total FROM users $where")
->fetch_assoc()['total'];
$totalPaginas = ceil($totalRegistros / $limit);
$sql = "SELECT id, nombre, dni, fecha_nacimiento, telefono, email, foto
FROM users $where ORDER BY id DESC LIMIT $offset, $limit";
$result = $conn->query($sql);
- Casteo
(int)$_POST['page']antes de inyectarlo — protección contra SQL injection en ese campo. real_escape_stringpara el término de búsqueda.- Hago dos queries: una para el total (paginación) y otra para los 10 registros visibles.
Respuesta JSON estándar
echo json_encode([
'data' => $usuarios,
'total' => $totalRegistros,
'page' => $page,
'total_pages' => $totalPaginas
]);
Acciones crear y actualizar
Idénticas salvo por el SQL (INSERT vs UPDATE). Ambas:
- Comprueban que
nombre,dniyfecha_nacimientono están vacíos. - Validan formato de DNI.
- Si hay teléfono, comprueban longitud mínima de 9.
- Escapan cada string con
real_escape_string. - Devuelven
{success:true, message:'...'}o{error:'...'}.
7 · Decisiones técnicas y por qué
api.php con action en vez de varios archivos?Content-Type centralizadas
en un solo sitio (DRY). Hacer list.php, create.php, update.php habría
duplicado código. En un proyecto real usaría un router (Slim, Symfony) o reescritura URL, pero para una
prueba esto es lo más claro y rápido.POST para listar en vez de GET?POST + action. Lo correcto en una API REST es GET para lecturas
(idempotentes, cacheables) y POST/PUT/DELETE para escrituras. Si me lo preguntan, lo digo
tal cual: "Lo simplifiqué a un solo verbo; en REST puro usaría GET /users?page=2&search=...".fetch() y async/await, que es nativo y no requiere
librería externa.required y minlength del HTML son sólo una
conveniencia.$_FILES y
move_uploaded_file.ORDER BY id DESC?id es AUTO_INCREMENT,
DESC = orden inverso de creación.8 · Validaciones implementadas
| Campo | Cliente (HTML) | Servidor (PHP) |
|---|---|---|
| Nombre | required |
empty($nombre) + real_escape_string |
| DNI | required minlength=9 maxlength=9 |
Regex /^[0-9]{8}[A-Z]$/ |
| Fecha nacimiento | type=date required |
empty($fecha_nacimiento) (formato lo garantiza el input date) |
| Teléfono | minlength=9 maxlength=20 |
Si no está vacío → strlen < 9 rechaza |
type=email |
(no validado explícitamente — mejora pendiente) | |
| Foto | Radio button preseleccionado | Fallback a undraw_profile.svg si llega vacío |
- El email no se valida server-side.
filter_var($email, FILTER_VALIDATE_EMAIL)sería el fix. - El DNI sólo se valida en formato, no la letra de control.
- La fecha no comprueba que sea anterior a hoy ni rango plausible (1900<año<hoy).
- No hay control de DNI duplicado: si lo añado, devolveré 409 Conflict.
9 · Seguridad · vulnerabilidades y cómo las cerraría
9.1 · SQL Injection · riesgo bajo pero existe
Uso real_escape_string, lo cual protege, pero no es la mejor práctica. Lo correcto son prepared statements:
// Lo que hago ahora
$nombreEsc = $conn->real_escape_string($nombre);
$sql = "INSERT INTO users (nombre, dni, ...) VALUES ('$nombreEsc', ...)";
// Lo que debería hacer
$stmt = $conn->prepare("INSERT INTO users (nombre, dni, fecha_nacimiento, telefono, email, foto)
VALUES (?, ?, ?, ?, ?, ?)");
$stmt->bind_param('ssssss', $nombre, $dni, $fechaNacimiento, $telefono, $email, $foto);
$stmt->execute();
Por qué importa: con prepared statements el driver envía los valores por separado del SQL, por lo que nunca se pueden interpretar como sintaxis. real_escape_string depende de que no se olvide nunca y de que el charset esté bien configurado.
9.2 · XSS · vulnerabilidad real en el frontend
En usuarios.js concateno el HTML así:
filas += `<td>${usuario.nombre}</td>`;
Si un usuario malicioso (o un compañero) crea uno con nombre <script>alert(1)</script>, el script se ejecuta en el navegador de todos los que vean el listado.
Fix: escapar antes de insertar:
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({
'&':'&','<':'<','>':'>','"':'"',"'":'''
}[c]));
}
// uso:
filas += `<td>${escapeHtml(usuario.nombre)}</td>`;
O usar $.text() en vez de $.html(), o construir nodos con document.createElement.
9.3 · CSRF · no hay token
Cualquier página externa podría hacer POST a php/api.php con cookies del usuario. Fix: token CSRF en sesión, enviado en cada AJAX por header X-CSRF-Token y verificado en PHP.
9.4 · No hay autenticación
Cualquiera con acceso a la URL puede listar, editar y borrar. La prueba no lo pedía, pero en producción haría:
- Login con sesiones PHP o JWT.
- Verificación de sesión al inicio de
api.phpantes de procesar la acción. - Roles (admin/usuario) si fueran necesarios.
9.5 · Credenciales hardcodeadas
$username = 'root';
$password = '';
Problema: si subo esto a Git con credenciales reales, fuga. Fix: variables de entorno (getenv('DB_PASS')) o un archivo config.php excluido por .gitignore.
9.6 · Errores genéricos
Hoy devuelvo {"error":"Error creating user"} sin detalle. Bien para no filtrar información a un atacante, pero malo para debug. Solución: loguear el error real (error_log) y devolver un mensaje genérico al cliente.
9.7 · Cierre rápido del checklist OWASP Top 10
| OWASP | Estado | Mitigación |
|---|---|---|
| A01 Broken Access Control | vulnerable | Añadir auth |
| A03 Injection | parcial | Prepared statements |
| A05 Misconfiguration | parcial | Quitar credenciales hardcodeadas |
| A07 Auth Failures | vulnerable | Login obligatorio |
| XSS (era A07 en 2017) | vulnerable | Escape en render |
10 · Optimización y mejoras técnicas
Hoy
Cada tecla en el buscador → 1 petición HTTP.
Si tecleo "antonio" → 7 peticiones, las 6 primeras se desperdician.
Con debounce
Espero 300 ms tras la última tecla y disparo una sola petición.
function debounce(fn, ms = 300) {
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
$('#inputBuscar').on('keyup', debounce(function() {
terminoBusqueda = $(this).val().trim();
cargarUsuarios(1);
}, 300));
10.2 · Índices en BBDD para el buscador
El LIKE '%texto%' impide usar índices B-tree (porque empieza por comodín). Para tablas pequeñas no importa, pero a partir de ~50k registros se nota. Soluciones:
- Fulltext index:
ALTER TABLE users ADD FULLTEXT(nombre, email)+MATCH(...) AGAINST(...). - Migrar a buscador externo: Elasticsearch / Meilisearch para fuzzy search.
10.3 · Loaders y notificaciones decentes
Hoy uso alert(), que bloquea el hilo y es feísimo. Mejora:
// Mostrar spinner mientras carga
$('#tablaUsuarios tbody').html('<tr><td colspan="7" class="text-center"><i class="fa fa-spinner fa-spin"></i> Cargando...</td></tr>');
// En vez de alert(), un toast (SB Admin 2 trae Bootstrap toast):
function toast(msg, type='success') {
$('#toastContainer').append(`<div class="toast bg-${type}">${msg}</div>`);
}
10.4 · Validación reactiva en el formulario
Hoy el usuario sólo descubre que el DNI es inválido al enviar. Lo ideal es validar on blur del campo y marcar en rojo en cuanto pierde foco.
10.5 · Paginación más rica
Hoy sólo tengo "Anterior / Siguiente". En 30 segundos puedo añadir botones numerados:
function generarPaginacion(total, actual) {
let html = `<button id="btnAnterior" ${actual<=1?'disabled':''}>«</button>`;
for (let i = 1; i <= total; i++) {
html += `<button class="btn-page ${i===actual?'active':''}" data-page="${i}">${i}</button>`;
}
html += `<button id="btnSiguiente" ${actual>=total?'disabled':''}>»</button>`;
$('#paginacion').html(html);
}
10.6 · Una sola query para listar + total
Hoy hago dos queries: COUNT(*) y SELECT. En MySQL puedo combinarlas con SQL_CALC_FOUND_ROWS (legacy) o con una subquery, aunque para tablas pequeñas la diferencia es despreciable y la legibilidad del código actual es mejor.
10.7 · Caching del listado
Para tablas con muchos hits y pocos cambios, cachear el JSON en Redis con TTL de 30s reduce drásticamente las queries.
11 · Cómo lo refactorizaría hoy (versión moderna)
11.1 · Backend con prepared statements + PDO
// db.php
function db(): PDO {
static $pdo = null;
if ($pdo === null) {
$pdo = new PDO(
'mysql:host=localhost;dbname=pruebatecnica;charset=utf8mb4',
getenv('DB_USER') ?: 'root',
getenv('DB_PASS') ?: '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
return $pdo;
}
// ListUsers.php
function listUsers(int $page, string $search): array {
$limit = 10; $offset = ($page - 1) * $limit;
$params = [];
$where = '';
if ($search !== '') {
$where = "WHERE nombre LIKE :s OR email LIKE :s OR dni LIKE :s OR telefono LIKE :s";
$params[':s'] = "%$search%";
}
$total = db()->prepare("SELECT COUNT(*) FROM users $where");
$total->execute($params);
$totalRows = (int) $total->fetchColumn();
$stmt = db()->prepare("SELECT id, nombre, dni, fecha_nacimiento, telefono, email, foto
FROM users $where ORDER BY id DESC LIMIT :limit OFFSET :offset");
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return ['data' => $stmt->fetchAll(), 'total' => $totalRows,
'page' => $page, 'total_pages' => (int) ceil($totalRows / $limit)];
}
11.2 · Cliente con fetch + async/await (sin jQuery)
async function cargarUsuarios(page = 1) {
const params = new URLSearchParams({ action: 'listar', page, search: terminoBusqueda });
const res = await fetch('php/api.php', { method: 'POST', body: params });
const data = await res.json();
if (data.error) return mostrarError(data.error);
renderTabla(data.data);
renderPaginacion(data.total_pages, data.page);
}
11.3 · REST verbos correctos
| Operación | Hoy | REST correcto |
|---|---|---|
| Listar | POST api.php action=listar | GET /api/users?page=1&q=... |
| Detalle | POST api.php action=obtener | GET /api/users/{id} |
| Crear | POST api.php action=crear | POST /api/users |
| Editar | POST api.php action=actualizar | PUT /api/users/{id} |
| Borrar | (no implementado) | DELETE /api/users/{id} |
11.4 · Stack moderno alternativo
- Backend: Laravel/Lumen, Symfony, Slim, o Node.js (Express/Fastify) si TypeScript.
- Frontend: Vue 3 o React + TypeScript, con un componente
UserListyUserForm. - Estado: React Query / TanStack Query para cache, refetch automático y optimistic updates.
- UI: Mantine, shadcn/ui o Material — más profesional que un template estático.
- BBDD: sigue siendo MySQL o PostgreSQL. ORM (Eloquent/Prisma) para tipado.
12 · Banco de preguntas probables y respuestas
users con id, nombre, dni, fecha_nacimiento, telefono, email, foto.$.ajax; nativamente con fetch() o XMLHttpRequest.GET, POST, PUT, DELETE, PATCH). Cada recurso tiene una URL única (/users/42) y el verbo define la operación. Es stateless: cada petición lleva toda la info necesaria.real_escape_string y casteo a (int) los IDs/páginas. La mejor práctica son prepared statements (PDO o mysqli con bind_param), porque separan el SQL de los datos en el protocolo del driver. Es lo que pondría en producción.& → &, < → <, etc.). En jQuery, usar .text() en vez de .html(). Reconozco que en mi prueba inyecto los nombres con plantilla literal, lo cual sí es vulnerable — lo corregiría con una función escapeHtml.POST y GET?GET es para leer (idempotente, cacheable, parámetros visibles en la URL). POST es para crear o modificar (no idempotente por defecto, body separado, no cacheable). En mi prueba uso POST para todo por uniformidad, pero en REST GET sería el verbo correcto para listar.$(document).ready() y por qué?<script> al final del body o con defer..btn-editar y los de paginación, que se recrean con cada $.html().- Migrar a prepared statements y validación robusta (sí o sí).
- Índice en
dniy un índice fulltext ennombre, emailpara el buscador. - Paginación por cursor (
WHERE id < ?) en vez deOFFSET, que se vuelve lento con páginas profundas. - Cache de Redis para las queries más comunes.
- Compresión gzip + HTTP/2.
- Servidor PHP en un pool con balanceador (HAProxy/Nginx) y MySQL en réplica master/slave para escalar lecturas.
- Buscador externo (Meilisearch / Elastic) si el LIKE deja de rendir.
utf8mb4 y no utf8?utf8 de MySQL es un alias engañoso: sólo soporta hasta 3 bytes por carácter, por lo que no soporta emojis ni algunos caracteres chinos. utf8mb4 es el UTF-8 real, hasta 4 bytes. Siempre utf8mb4.- Backend: PHPUnit. Test unitarios para
esDniValido()(con casos buenos y malos), tests de integración contra una BBDD de test que se reinicia con fixtures. - Frontend: Jest + Testing Library, o Cypress para tests end-to-end (login, crear usuario, buscar, paginar).
- API: Postman collections o tests con
guzzlehttp/guzzleen PHP.
- Añado un botón
btn-borraren cada fila condata-id. - Listener delegado al
documentcon confirmación (SweetAlertidealmente,confirm()mínimo). - Nueva acción
borrarenapi.phpconDELETE FROM users WHERE id = ?(prepared). - Tras éxito, recargo el listado (
cargarUsuarios(paginaActual)). - Plus: soft delete con una columna
deleted_aten vez de borrar físico, para poder restaurar.
mysqli y PDO?mysqli sólo habla MySQL. PDO es una abstracción sobre múltiples drivers (MySQL, PostgreSQL, SQLite...). PDO es más portable y tiene una API más limpia para prepared statements. mysqli ofrece algunos métodos específicos de MySQL ligeramente más rápidos. En proyectos nuevos suelo usar PDO.interface Usuario compartida cliente-servidor (con OpenAPI o un generador).main. En la prueba no inicialicé repo, pero en un proyecto real haría git init desde el minuto cero, con un .gitignore que excluya vendor/, node_modules/ y archivos con credenciales.Access-Control-Allow-Origin. En mi prueba no hay CORS porque cliente y servidor están en el mismo origen (localhost). Aparecería si separase el front en otro dominio.13 · Cómo presentar el código en vivo
- Arrancar XAMPP: Apache + MySQL en verde. Abrir
http://localhost/pruebatecnica/tables.html. - Mostrar el listado: 10 registros, paginación abajo, búsqueda arriba.
- Buscar: teclear "antonio" y comentar: "Lo correcto sería un debounce de 300 ms; lo añadiría en producción".
- Paginar: ir a página 2, mostrar que la numeración de la primera columna sigue correcta (
(página-1)*10 + index + 1). - Editar: abrir un usuario, cambiar el teléfono, guardar. Mostrar que se actualiza sin recargar.
- Crear: intentar guardar con DNI inválido para enseñar la validación.
- Abrir DevTools → Network: repetir una acción y mostrar el JSON de ida y vuelta. Esto demuestra que entiendes lo que pasa "por debajo".
- Abrir el código: abrir
api.phpen la sección de validación de DNI y explicar la regex. - Cerrar con una mejora: "Lo siguiente que añadiría sería X" (debounce, prepared statements o auth). Demuestra visión.
14 · Cheatsheet · lo último que repasarás
Archivos modificados
tables.htmljs/usuarios.jsphp/api.php
BBDD
- db:
pruebatecnica - tabla:
users - charset:
utf8mb4
Acciones API
listar(page, search)obtener(id)crear(datos)actualizar(id, datos)
Validaciones
- DNI: regex
/^\d{8}[A-Z]$/ - Obligatorios: nombre, dni, fecha
- Teléfono mín. 9
3 mejoras que siempre mencionar
- Prepared statements (PDO).
- Escape XSS en el render.
- Debounce del buscador.
3 conceptos que tienes que dominar
- Event delegation.
- Por qué validar en cliente y servidor.
- Idempotencia de los verbos HTTP.
- Si no sabes algo, dilo y explica cómo lo investigarías. Es la mejor respuesta posible.
- Cuando expliques código, habla del por qué más que del qué.
- Las mejoras que reconoces voluntariamente antes de que te las señalen suman muchísimo.
- Pregunta tú también: por el equipo, el stack real, los retos de la posición. Demuestra interés.
Mucha suerte mañana. Estás preparado.