Formato confirmado — martes 8 sep, 8:00 am CDMX.
En vivo, pantalla compartida, tu editor. Construyes 3 piezas: (1) una consulta SQL a mano, (2) un método de servicio .NET que la ejecuta, (3) el endpoint que la expone. Más una parte hablada de Angular. Guion completo en la pestaña La prueba.
Lo que NO necesitas: proyecto suyo, su base de datos, compilar, ejecutar. Un .cs suelto basta. Te dan las firmas de sus clases por chat — no se mide memoria. Sin documentación, internet ni IA: apaga Copilot / la extensión de Claude antes de entrar.
| # | Foco | Por qué | Dónde |
|---|---|---|---|
| 1 | Joins + paginación (OFFSET/FETCH y keyset) + transacciones en SQL Server | Es la pregunta que decide | Pestaña SQL |
| 2 | Acceso a datos "a mano": Dapper / ADO.NET con IDbTransaction | "La mitad del trabajo" según ellos | Pestaña SQL |
| 3 | AWS→Azure: tabla de equivalencias y no-equivalencias | Pregunta 4, tu punto débil de cloud | Pestaña Cloud |
| 4 | Respuesta honesta y ensayada sobre tu inglés | Pregunta 5, la van a testear | Pestaña Preguntas |
| 5 | Native Federation: beneficios y dolores concretos, poder dibujarlo | Pregunta 2, tu fuerte — hay que lucirlo | Pestaña Angular |
Red de seguridad: si no pasa la prueba de SQL, sigues siendo candidato fuerte para el puesto de Front End Developer que NEWCO también tiene abierto. No es un fracaso — pero el objetivo es Full-Stack.
Martes 8 sep, 8:00 am CDMX. Pantalla compartida, tu editor, un fichero suelto. No compila nada. Se valora el código y lo que dices mientras lo escribes.
El correo de Alberto, en claro:
Prepara tu ejemplo AHORA — no lo inventes en vivo. El dominio y las tablas los eliges tú; las firmas de código te las dan por chat y te adaptas (sección abajo). Llega con el esquema decidido y practicado: un reporte con JOIN de 3 tablas + filtro de fecha + paginación. Mantenlo mínimo — 3 tablas, sin adornos:
[Order] (OrderId PK, OrderDate datetime2, Status)
Product (ProductId PK, Sku, Name)
OrderLine (OrderLineId PK, OrderId FK→[Order], ProductId FK→Product, Quantity, UnitPrice)
Al empezar, tú lo anuncias: "Voy a hacer un reporte de líneas de pedido en un rango de fechas, paginado. Contra estas tres tablas —las dibujo—: un pedido, sus líneas, y el producto de cada línea. Si prefieren otra cosa, díganme."
El ejemplo ya está montado con datos reales — para practicar la consulta contra una BD de verdad (en la prueba no la tendrás, pero para ensayar sí ayuda):
datos-prueba.sql → crea la BD PruebaCocoMarch en localhost\SQLEXPRESS con las 3 tablas y datos de Vita Tienda (10 productos, 20 pedidos entre dic-2025 y feb-2026, 37 líneas). Ya ejecutado. Re-crear: sqlcmd -S "localhost\SQLEXPRESS" -E -C -i datos-prueba.sqlverificar.sql → la consulta de la prueba corriendo, más la demo del rango semiabierto (pedido del 31-ene 23:30 entra, el del 1-feb 00:00 no) y de la paginación determinista.prueba.cs (Escritorio) → arranca con el esqueleto en blanco (encabezado + using + los 3 marcadores). El objetivo completo está en "El archivo completo" abajo.reset-prueba.bat → deja prueba.cs en blanco y lo abre en VS Code. Tecleas la solución narrando, ejecutas verificar.sql para comprobar, y repites. reset-bd.bat recrea la BD si ensayaste las variaciones de INSERT.Server=localhost\SQLEXPRESS;Database=PruebaCocoMarch;Trusted_Connection=True;TrustServerCertificate=TrueDatos pensados para lucir el filtro: con fromDate = 2026-01-01 y toExclusive = 2026-02-01 salen 22 líneas de enero; diciembre y febrero quedan fuera, y el pedido a medianoche del 1-feb demuestra por qué < @toExclusive y no BETWEEN.
Haces: VS Code ▸ Ctrl+Shift+X ▸ escribe "Claude" ▸ en Claude Code pulsa Disable. Repite con "Copilot" si aparece. Luego Ctrl+Shift+P ▸ "Reload Window".
Por qué: Alberto lo pide explícito y en el screen share se nota si un autocompletado fantasma te sugiere líneas enteras. Que no haya ninguna duda de que el código es tuyo.
Haces: Ctrl+N (archivo nuevo) ▸ Ctrl+S ▸ guárdalo como prueba.cs en el Escritorio.
Por qué: "un fichero suelto" es literal — no abras carpeta, no crees proyecto, no hay que compilar. La extensión .cs es solo para que VS Code lo coloree y se lea ordenado en pantalla.
Haces: el editor ocupando ~⅔, y la ventana de la reunión con el chat abierto en el ⅓ restante (o en un segundo monitor). Ahí van a pegar las firmas.
Por qué: si tienes que Alt+Tab cada vez que pegan algo, pierdes el hilo y se te ve titubear. Con el chat siempre a la vista, copias y sigues.
Haces: auriculares con micro (no el del portátil). Entra 2 min antes, comparte una pantalla de prueba, confirma que se te oye.
Por qué: "si te quedas en silencio no podemos valorar lo que haces" — el audio es la evaluación. Un corte de sonido a mitad te cuesta la prueba.
Dices: "Perfecto. Entonces necesitan tres cosas: una consulta SQL a mano, un método de servicio en .NET que la ejecute, y el endpoint que la expone. Trabajo en este fichero suelto y les voy narrando. ¿Correcto?"
Por qué: confirmas que entendiste, fijas el marco, y les cedes el turno para matizar antes de teclear nada.
Dices: "Como el dominio lo elijo yo, voy con un reporte de líneas de pedido en un rango de fechas, paginado — ejercita bien el JOIN, el filtro y la paginación. Contra tres tablas: un pedido, sus líneas, y el producto de cada línea. Si prefieren otra cosa, díganme."
Por qué: propones, no preguntas "¿qué quieren?". Eliges un caso realista y explicas por qué luce lo que evalúan. Dejas la puerta abierta por si tienen preferencia.
Escribes:
// Prueba tecnica - reporte de lineas de pedido por rango de fechas, paginado
//
// Mis 3 tablas:
// [Order] (OrderId PK, OrderDate, Status)
// Product (ProductId PK, Sku, Name)
// OrderLine (OrderLineId PK, OrderId -> [Order], ProductId -> Product, Quantity, UnitPrice)
//
// Firmas que me pasen por el chat:
// (pegar aqui)
Dices: "Dejo el esquema escrito para tenerlo a la vista, y un hueco para las firmas que me pasen."
Por qué: el esquema en pantalla te ancla y les enseña el modelo. El hueco evita que pierdas las firmas en el scroll del chat.
Por qué: traduces un requerimiento a una spec — es literal lo que pide el puesto. Y te ahorra rehacer a mitad.
Dónde trabajas de aquí en adelante: un solo archivo, prueba.cs, abierto en VS Code. Nada más. No abres SSMS, no ejecutas nada, no hay proyecto — el correo lo dice: "no hace falta que compiles ni ejecutes". (SSMS y la BD PruebaCocoMarch fueron solo para tu práctica de estos días.)
La consulta SQL se escribe a mano dentro de prueba.cs: primero como un bloque de comentario /* … */ para pensarla, y luego la copias a una cadena de C# en el método. Así queda el archivo:
prueba.cs
├─ // encabezado: esquema + hueco para firmas ← Paso 1.3 y 2.2
├─ /* ---- La consulta ---- ... ---- fin ---- */ ← Paso 3 (SQL a mano)
├─ using System.Data; using Dapper; ... ← Paso 4.1
├─ public class OrderReportService(...) { ... } ← Paso 4 (método de servicio)
├─ [ApiController] public class OrderReportController(...) { ... } ← Paso 5 (endpoint)
└─ public record OrderLineRow(...); public record PagedResult<T>(...); ← al final (si no te dan los tipos)
Las 3 piezas que siguen son un esqueleto invariante. Lo único que cambia con sus firmas son nombres y formas. Ese ajuste es la habilidad.
PagedResult<T> o como lo llamen), a veces un objeto de query y/o una interfaz de servicio.prueba.cs. Que se vea en pantalla — es tu contrato.GetOrderLinesAsync, devuelve Task<PagedResult<OrderLineDto>> y recibe ReportQuery. OrderLineDto tiene OrderLineId, OrderId, OrderDate, ProductName, LineTotal. ¿Lo leo bien?" — gana tiempo y demuestra que lo miraste.| Te dan… | Preguntas |
|---|---|
| Un DTO / fila | ¿LineTotal/Total/Amount lo calculo yo (Quantity*UnitPrice) o es columna? Si trae un campo que mi base no tiene (p. ej. CustomerName) → "añado un JOIN a esa tabla". Si no trae OrderLineId → "¿esto es agregado por pedido, no por línea?" |
| Un objeto de query | ¿PageIndex/Page es base 0 o base 1? ¿ToExclusive es exclusivo de verdad? ¿PageSize tiene tope o lo pongo yo? |
| Un tipo de resultado | ¿Total/Count es el total filtrado o el de la tabla? ¿record posicional o propiedades? ¿lleva PageIndex/PageSize o solo Items/Total? |
| Una interfaz de servicio | ¿La implemento en una clase concreta? ¿Cómo llega la conexión — IDbConnection inyectado, connection string? (asumo constructor y lo digo) |
| Firma | Dónde encaja |
|---|---|
| Campos del DTO | Los alias del SELECT — Dapper mapea por nombre, cada alias debe coincidir exacto. Y el ReadAsync<SuDto>(). |
| Objeto de query | Los parámetros del método (ReportQuery q en vez de 4 sueltos) y el binding del endpoint ([FromQuery] ReportQuery q). Dentro usas q.From… |
| Tipo de resultado | El return new SuTipo(...) y el ActionResult<SuTipo> del endpoint. |
| Interfaz de servicio | public class OrderReportService : ISuInterfaz + la firma del método copiada literal. |
| Nombre del método | Ese, tal cual. No lo "mejores". |
Te pegan esto en el chat:
public record ReportQuery(DateTime From, DateTime ToExclusive, int Page, int Size);
public record OrderSummaryDto(int OrderId, DateTime OrderDate, string CustomerName, decimal Total);
public record Paged<T>(IReadOnlyList<T> Rows, long Count);
public interface IOrderReport { Task<Paged<OrderSummaryDto>> RunAsync(ReportQuery q); }
Lo que dices y haces — comparas con tu base:
OrderLineId, trae un Total por pedido → es agregado por pedido. Agrupo y sumo."CustomerName, que mi base no tenía → añado JOIN a Customer. Y como no muestro producto, quito el JOIN a Product." Mismo patrón, distinto set de tablas.Count es long → leo el COUNT(*) como long: ReadSingleAsync<long>()."public async Task<Paged<OrderSummaryDto>> RunAsync(ReportQuery q), usas q.From, q.Size… Return: new Paged<OrderSummaryDto>(rows, count). Endpoint: [FromQuery] ReportQuery q.Si NO te dan firmas y dicen "estructúralo como lo harías": usas las tuyas (Paso 3–5) y explicas cada decisión. Prepárate para ambos.
La escribes como SQL puro primero, en un bloque comentado, para pensarla sin pelear con el C#. Luego la mueves al método. La construyes por trozos, narrando.
Escribes (debajo del encabezado, en prueba.cs):
/* ---- La consulta ----
SELECT
ol.OrderLineId,
o.OrderId,
o.OrderDate,
p.Name AS ProductName,
ol.Quantity,
ol.UnitPrice,
ol.Quantity * ol.UnitPrice AS LineTotal
Dices: "Empiezo por lo que devuelve el reporte: id de línea, datos del pedido, nombre del producto, cantidad y precio. El total de línea no lo guardo, lo calculo — Quantity * UnitPrice."
Por qué: arrancar por el SELECT te obliga a decidir la forma de la fila antes que nada. Alias legibles (ProductName, LineTotal) para que el DTO mapee por nombre. Total calculado = no duplicas un dato derivable.
Escribes:
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
INNER JOIN dbo.Product AS p ON p.ProductId = ol.ProductId
Dices: "Arranco por OrderLine, que es el grano del reporte — una fila por línea. Le pego el pedido por OrderId y el producto por ProductId. INNER porque solo quiero líneas completas; si quisiera incluir líneas sin producto sería LEFT. Alias cortos y prefijo todas las columnas."
Por qué: empezar por la tabla más fina y colgar las demás es el patrón estándar de un reporte tipo estrella. [Order] entre corchetes porque Order es palabra reservada. Prefijar columnas evita ambigüedad.
Escribes:
WHERE o.OrderDate >= @fromDate
AND o.OrderDate < @toExclusive
Dices: "Rango semiabierto: mayor o igual que el inicio, y estrictamente menor que el fin. No uso BETWEEN porque incluye el extremo, y con datetime eso te mete o te deja fuera el último día según la hora. El cliente manda como toExclusive el primer instante del día siguiente. Y filtro contra OrderDate tal cual, sin CAST ni CONVERT, para que el predicado sea SARGable y pueda usar un índice."
Por qué: es EL detalle que separa a alguien que ha hecho reportes de fechas de alguien que no. BETWEEN + datetime = bug clásico de "me falta o me sobra el último día". CAST(OrderDate AS date) = el motor no puede usar el índice → scan.
Escribes:
ORDER BY o.OrderDate DESC, ol.OrderLineId DESC
Dices: "Ordeno por fecha descendente, que es lo que le importa al usuario, y desempato con OrderLineId, que es único. OFFSET/FETCH necesita un orden total: si dos filas tienen la misma fecha y no hay desempate, el motor las puede devolver en cualquier orden y al paginar se repiten o se saltan filas."
Por qué: paginación sin orden determinista = filas duplicadas o perdidas entre páginas. La columna final tiene que ser única (la PK sirve). Es sutil y es justo lo que quieren oírte razonar.
Escribes:
OFFSET (@pageIndex * @pageSize) ROWS FETCH NEXT @pageSize ROWS ONLY;
Dices: "Paginación en el servidor: salto pageIndex * pageSize filas y traigo las siguientes pageSize. Para un reporte donde saltas a la página N, OFFSET está bien. Si fuera scroll infinito usaría keyset — filtrar por la última fila vista — que no se degrada al avanzar."
Por qué: OFFSET es simple y correcto aquí; mencionar keyset muestra que sabes su límite (en la página 10.000, OFFSET aún lee y descarta 100.000 filas) sin complicar el ejercicio.
Escribes (y cierras el bloque):
SELECT COUNT(*)
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
WHERE o.OrderDate >= @fromDate
AND o.OrderDate < @toExclusive;
---- fin ---- */
Dices: "Y el total para el paginador, con el mismo WHERE pero sin el JOIN a producto ni el ORDER BY, que no hacen falta para contar. Los dos SELECT los mando juntos, un solo viaje a la base."
Por qué: el paginador necesita el total de filas que cumplen el filtro, no el de la tabla. Contar no necesita ordenar ni el join a Product.
Si preguntan por índices: "Un índice en [Order](OrderDate) para el filtro y el orden; y las FK de OrderLine (OrderId, ProductId) indexadas para el JOIN. Con eso no hay scan."
Escribes (debajo del bloque SQL; usa la firma que te dieron, o esta):
using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;
public class OrderReportService(string connectionString)
{
public async Task<PagedResult<OrderLineRow>> GetOrderLinesAsync(
DateTime fromDate, DateTime toExclusive, int pageIndex, int pageSize)
{
}
}
Dices: "El servicio recibe el connection string por constructor — en real vendría de configuración e inyección de dependencias. El método es async porque va a la base."
Por qué: si te dieron una interfaz, aquí pones : IEsaInterfaz y copias la firma literal. El primary constructor (string connectionString) es C# moderno, ahorra ceremonia.
Escribes (dentro del método) — copias la consulta del bloque de arriba a una cadena:
const string sql = @"
SELECT ol.OrderLineId, o.OrderId, o.OrderDate,
p.Name AS ProductName, ol.Quantity, ol.UnitPrice,
ol.Quantity * ol.UnitPrice AS LineTotal
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
INNER JOIN dbo.Product AS p ON p.ProductId = ol.ProductId
WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive
ORDER BY o.OrderDate DESC, ol.OrderLineId DESC
OFFSET (@pageIndex * @pageSize) ROWS FETCH NEXT @pageSize ROWS ONLY;
SELECT COUNT(*)
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive;";
Dices: "Muevo la consulta a una cadena literal — el @ me deja escribirla en varias líneas. Es la misma de antes."
Por qué: pensaste la SQL como SQL; ahora es un dato del método. const porque no cambia.
Escribes:
using var conn = new SqlConnection(connectionString);
using var grid = await conn.QueryMultipleAsync(sql,
new { fromDate, toExclusive, pageIndex, pageSize });
Dices: "Abro la conexión con using para que se cierre sola. Los parámetros van como objeto anónimo — Dapper los convierte en parámetros de SqlCommand, nunca los concateno en el texto: eso es lo que mata la inyección SQL. Y como son dos SELECT, QueryMultiple: un viaje, leo los dos resultados."
Por qué: parametrizar = seguridad + plan de ejecución reutilizable. using = no fugas de conexión. QueryMultiple = lista + count en un round-trip.
Escribes:
var items = (await grid.ReadAsync<OrderLineRow>()).ToList();
var total = await grid.ReadSingleAsync<int>();
return new PagedResult<OrderLineRow>(items, total, pageIndex, pageSize);
Dices: "Leo la primera consulta como la lista de filas, la segunda como un entero — el total. Y devuelvo el resultado paginado con lo que el cliente necesita para pintar el paginador."
Por qué: el orden de los Read sigue el orden de los SELECT. Si el tipo de resultado que te dieron se llama distinto o tiene otros campos, aquí lo ajustas (Paso 2).
Escribes (debajo del servicio):
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/reports")]
public class OrderReportController(OrderReportService service) : ControllerBase
{
[HttpGet("order-lines")]
public async Task<ActionResult<PagedResult<OrderLineRow>>> GetOrderLines(
[FromQuery] DateTime fromDate,
[FromQuery] DateTime toExclusive,
[FromQuery] int pageIndex = 0,
[FromQuery] int pageSize = 20)
{
}
}
Dices: "GET porque es una lectura. Los filtros van en la query string: fromDate, toExclusive, y la paginación con valores por defecto. El servicio entra por constructor."
Por qué: [ApiController] te da el binding y las respuestas de error automáticas. ActionResult<T> te deja devolver el dato o un código de error.
Escribes (dentro de la acción):
if (toExclusive <= fromDate)
return BadRequest("El rango de fechas no es valido.");
pageIndex = Math.Max(0, pageIndex);
pageSize = Math.Clamp(pageSize, 1, 100);
Dices: "Valido lo que el servicio no puede saber: que el rango tenga sentido → 400. Y saneo la paginación aquí, en el borde: pageIndex no negativo, pageSize con tope de 100, para que nadie pida un millón de filas de golpe."
Por qué: el controller es la frontera con el mundo — es donde se valida la entrada. El tope de página protege la base.
Escribes:
var result = await service.GetOrderLinesAsync(fromDate, toExclusive, pageIndex, pageSize);
return Ok(result);
Dices: "Llamo al servicio y devuelvo 200 con el resultado. Si no hay filas en ese rango, sigue siendo 200 con lista vacía — no 404: el reporte existe, simplemente no tiene datos ese mes. El controller queda fino: valida, delega, devuelve. Nada de SQL aquí."
Por qué: separación de capas. 404 es "el recurso no existe", no "la consulta no trajo nada".
Antes de decir "listo", recorre el código señalando en pantalla y recita:
ORDER BY determinista que termina en columna única."using en conexión y grid."SELECT: no necesita transacción explícita. Corre bajo READ COMMITTED por defecto y me vale."SELECT (lista y COUNT). Entre uno y otro podría colarse un INSERT y que el total no cuadre con la página. Si esa consistencia importa, envuelvo los dos en una transacción con aislamiento SNAPSHOT —lectura consistente sin bloquear escritores— o REPEATABLE READ."Order + sus OrderLine + descontar Stock. O las tres o ninguna."conn.BeginTransaction(), se lo paso a cada Execute, Commit() al final, Rollback() en el catch. El using de la transacción hace rollback si salgo por excepción sin llegar al commit."SERIALIZABLE solo si necesito que nadie inserte en el rango que estoy leyendo mientras decido —por ejemplo, validar que no haya solapamiento antes de insertar."Casi seguro te preguntan (por el JD y las notas):
| Pregunta | Núcleo de la respuesta |
|---|---|
| Observable vs Promesa | Promesa: un valor, ansiosa (arranca sola), no se cancela. Observable: 0..n valores en el tiempo, perezoso (no pasa nada sin subscribe), cancelable, y con operadores. HTTP one-shot: casi da igual (HttpClient ya devuelve Observable). Búsquedas, streams, cancelación: Observable gana. |
| RxJS que usas | switchMap (cancela la anterior — typeahead), debounceTime+distinctUntilChanged, catchError, combineLatest, shareReplay (cachear), takeUntilDestroyed (limpiar). mergeMap vs concatMap vs switchMap: paralelo / en orden / solo la última. |
| Fuga de memoria / unsubscribe | Un subscribe sin cerrar sigue vivo tras destruir el componente. Se evita con async pipe (Angular gestiona), takeUntilDestroyed(), o takeUntil(destroy$) en ngOnDestroy. |
| Native Federation: aportó / costó | Aportó: deploys independientes por módulo, equipos que no se pisan, no rebuild del monolito. Costó: alinear versiones de Angular y shared deps (mal configurado revienta en runtime), debug entre host y remoto, primer setup del federation.config. Ten un caso concreto: "un módulo que actualizamos sin tocar los otros 17". |
| Change detection | Zone.js parchea lo async y marca el árbol como dirty; Angular re-evalúa. OnPush: solo si cambia una @Input por referencia, un evento del template, o un observable con async. Signals (v16+): CD granular, solo lo que lee esa señal. Angular va a zoneless. |
| Signals | signal() valor reactivo, computed() derivado que se recalcula solo, effect() efecto secundario. Vs RxJS: signals para estado síncrono en la vista; RxJS para eventos async y streams. input()/output() como signals desde v17. |
| Lifecycle | constructor: solo DI, nada de lógica. ngOnInit: arranque (llamadas, subscribe). ngOnChanges: reacciona a cambios de @Input. ngOnDestroy: limpiar. ngAfterViewInit: ya hay @ViewChild. |
| Servicios y DI | @Injectable({ providedIn: 'root' }) = singleton tree-shakeable. Inyector jerárquico: un provider en un componente da una instancia por ese subárbol. inject() function-style en vez de constructor. |
| Routing | Lazy con loadComponent/loadChildren. Guards (CanActivate) para auth. Resolvers para precargar datos. ActivatedRoute para params. Se declara con provideRouter(routes). |
| HTTP interceptors | Función que envuelve cada request: meter el token, logging, reintentos, manejo global de errores (401 → refresh o login). Se registran en provideHttpClient(withInterceptors([...])). |
| Standalone | Sin NgModule: cada componente declara sus imports. bootstrapApplication + provideHttpClient(), provideRouter(). Mejor tree-shaking, lazy por componente. |
| Estado | Para local: servicio con signal o BehaviorSubject + asObservable(). Para app grande y compleja: NgRx (store, actions, effects, selectors) — pero solo si el estado compartido lo justifica; si no, sobra. |
| Performance | OnPush, trackBy/@for track en listas, lazy loading, @defer para diferir bloques pesados, NgOptimizedImage, evitar funciones en el template. |
| Testing (Karma/Jasmine + Jest) | Jasmine = sintaxis (describe/it/expect, spies), Karma = navegador real. Jest = Node + jsdom, más rápido, snapshots. HttpTestingController (verificar request sin red), ComponentFixture + DebugElement (DOM), fakeAsync/tick para tiempo. |
| El último test que escribiste | Ten uno real: qué componente, qué comprobaba (un @Output que emite al hacer clic, un servicio mockeado, un estado de carga). Concreto > genérico. |
| Formularios | Reactivos y tipados (FormGroup<{...}>), validadores sync y async, que reflejan las reglas del back, valueChanges como Observable. |
| AWS Lambda → Azure | Equivale a Azure Functions (serverless, event-driven). No equivale 1:1: IAM ≠ Entra ID / Managed Identity; API Gateway ≠ API Management; DynamoDB ≠ Cosmos DB; S3 ≠ Blob Storage; CloudWatch ≠ App Insights. "He usado Lambda; en Azure me pondría al día con Functions y App Insights, el modelo mental es el mismo". |
Cuando te pregunten "¿tienes dudas?", ten 2–3 listas. Muestran interés real:
QueryMultipleAsync o similar; la idea es leer dos result sets." No se mide memoria.El esqueleto (Paso 3–5) no cambia — solo una cláusula o un método. Aquí, cada variante con el código y en qué parte del fichero va.
En el SQL — cambias solo el WHERE:
WHERE o.Status = @status
En el método — el parámetro y el objeto anónimo:
public async Task<PagedResult<OrderLineRow>> GetOrderLinesAsync(
string status, int pageIndex, int pageSize)
{
// ... mismo cuerpo ...
new { status, pageIndex, pageSize } // en QueryMultipleAsync
}
En el endpoint — [FromQuery] string status en vez de las fechas. Todo lo demás igual.
"Mismo patrón, distinto predicado. Status es una igualdad — = @status, no LIKE."
En el SQL — cambia el SELECT, entra GROUP BY, y el COUNT cuenta pedidos distintos:
SELECT
o.OrderId,
o.OrderDate,
COUNT(*) AS LineCount,
SUM(ol.Quantity * ol.UnitPrice) AS OrderTotal
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive
GROUP BY o.OrderId, o.OrderDate
ORDER BY o.OrderDate DESC, o.OrderId DESC
OFFSET (@pageIndex * @pageSize) ROWS FETCH NEXT @pageSize ROWS ONLY;
SELECT COUNT(DISTINCT o.OrderId)
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive;
El tipo de fila cambia a OrderSummaryRow(int OrderId, DateTime OrderDate, int LineCount, decimal OrderTotal). Método y endpoint: igual, solo el genérico.
"Ahora cada fila es un pedido, no una línea. GROUP BY por lo que no está agregado. El total del paginador es COUNT(DISTINCT o.OrderId) — si no, contaría líneas."
En el SQL — al WHERE:
AND (p.Name LIKE @term + '%' OR p.Sku LIKE @term + '%')
Método/endpoint — parámetro string term.
"Prefijo (@term + '%') puede usar índice. '%' + @term + '%' —contiene— ya no; si necesitan eso, full-text o acepto el scan, y lo digo."
SQL — sin paginación ni ORDER BY:
SELECT ol.OrderLineId, o.OrderId, o.OrderDate, p.Name AS ProductName,
ol.Quantity, ol.UnitPrice, ol.Quantity * ol.UnitPrice AS LineTotal
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
INNER JOIN dbo.Product AS p ON p.ProductId = ol.ProductId
WHERE ol.OrderLineId = @id;
Método:
public async Task<OrderLineRow?> GetByIdAsync(int id)
{
using var conn = new SqlConnection(connectionString);
return await conn.QuerySingleOrDefaultAsync<OrderLineRow>(sql, new { id });
}
Endpoint:
[HttpGet("order-lines/{id:int}")]
public async Task<ActionResult<OrderLineRow>> GetById(int id)
=> await service.GetByIdAsync(id) is { } row ? Ok(row) : NotFound();
"QuerySingleOrDefault devuelve null si no existe → 404. Single (sin OrDefault) reventaría; First ocultaría duplicados."
En el servicio — método nuevo:
public async Task<int> CreateOrderAsync(NewOrder input)
{
const string sql = @"
INSERT INTO dbo.[Order] (OrderDate, Status)
VALUES (@OrderDate, @Status);
SELECT CAST(SCOPE_IDENTITY() AS int);";
using var conn = new SqlConnection(connectionString);
return await conn.ExecuteScalarAsync<int>(sql, input);
}
En el endpoint:
[HttpPost("orders")]
public async Task<ActionResult<int>> Create(NewOrder input)
{
var id = await service.CreateOrderAsync(input);
return CreatedAtAction(nameof(GetById), new { id }, id);
}
"ExecuteScalarAsync<int> + SCOPE_IDENTITY() para devolver el id nuevo. 201 con Location apuntando al recurso creado. El 400 por body inválido lo da el binder si el DTO trae DataAnnotations."
En el servicio:
public async Task<int> CreateOrderWithLinesAsync(NewOrder order, IEnumerable<NewLine> lines)
{
using var conn = new SqlConnection(connectionString);
await conn.OpenAsync();
using var tx = conn.BeginTransaction();
try
{
var orderId = await conn.ExecuteScalarAsync<int>(
@"INSERT INTO dbo.[Order] (OrderDate, Status) VALUES (@OrderDate, @Status);
SELECT CAST(SCOPE_IDENTITY() AS int);",
order, tx);
foreach (var l in lines)
await conn.ExecuteAsync(
@"INSERT INTO dbo.OrderLine (OrderId, ProductId, Quantity, UnitPrice)
VALUES (@orderId, @ProductId, @Quantity, @UnitPrice);",
new { orderId, l.ProductId, l.Quantity, l.UnitPrice }, tx);
tx.Commit();
return orderId;
}
catch
{
tx.Rollback();
throw;
}
}
"Abro la conexión explícitamente con OpenAsync porque necesito la transacción. Se la paso a cada Execute. Commit al final; si algo truena, Rollback y relanzo. O todo o nada."
En el SQL — el cliente manda la última fila que vio, no un número de página:
WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive
AND (@lastDate IS NULL
OR o.OrderDate < @lastDate
OR (o.OrderDate = @lastDate AND ol.OrderLineId < @lastId))
ORDER BY o.OrderDate DESC, ol.OrderLineId DESC
OFFSET 0 ROWS FETCH NEXT @pageSize ROWS ONLY;
Método/endpoint — en vez de pageIndex, los parámetros DateTime? lastDate, int? lastId.
"Sin OFFSET que crezca: la primera página y la 10.000 cuestan lo mismo, porque el WHERE salta directo con el índice. El @lastDate IS NULL es la primera llamada."
using var conn = new SqlConnection(connectionString);
await conn.OpenAsync();
using var cmd = new SqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@fromDate", fromDate);
cmd.Parameters.AddWithValue("@toExclusive", toExclusive);
cmd.Parameters.AddWithValue("@pageIndex", pageIndex);
cmd.Parameters.AddWithValue("@pageSize", pageSize);
var items = new List<OrderLineRow>();
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
items.Add(new OrderLineRow(
reader.GetInt32(0), reader.GetInt32(1), reader.GetDateTime(2),
reader.GetString(3), reader.GetInt32(4),
reader.GetDecimal(5), reader.GetDecimal(6)));
await reader.NextResultAsync();
await reader.ReadAsync();
var total = reader.GetInt32(0);
"Lo mismo, más verboso: parámetros con cmd.Parameters, Read() en bucle, mapeo por índice de columna. NextResult para pasar al segundo SELECT. Dapper me ahorra exactamente este mapeo."
Así queda prueba.cs al final. Practica tecleándolo entero, narrando, hasta que salga en ~12–15 min sin trabarte.
// Prueba tecnica - reporte de lineas de pedido por rango de fechas, paginado
//
// Mis 3 tablas:
// [Order] (OrderId PK, OrderDate, Status)
// Product (ProductId PK, Sku, Name)
// OrderLine (OrderLineId PK, OrderId -> [Order], ProductId -> Product, Quantity, UnitPrice)
//
// Firmas del chat: (aqui va lo que me peguen)
using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;
using Microsoft.AspNetCore.Mvc;
// ---------- 1) El metodo de servicio ----------
public class OrderReportService(string connectionString)
{
public async Task<PagedResult<OrderLineRow>> GetOrderLinesAsync(
DateTime fromDate, DateTime toExclusive, int pageIndex, int pageSize)
{
const string sql = @"
SELECT ol.OrderLineId, o.OrderId, o.OrderDate,
p.Name AS ProductName, ol.Quantity, ol.UnitPrice,
ol.Quantity * ol.UnitPrice AS LineTotal
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
INNER JOIN dbo.Product AS p ON p.ProductId = ol.ProductId
WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive
ORDER BY o.OrderDate DESC, ol.OrderLineId DESC
OFFSET (@pageIndex * @pageSize) ROWS FETCH NEXT @pageSize ROWS ONLY;
SELECT COUNT(*)
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive;";
using var conn = new SqlConnection(connectionString);
using var grid = await conn.QueryMultipleAsync(sql,
new { fromDate, toExclusive, pageIndex, pageSize });
var items = (await grid.ReadAsync<OrderLineRow>()).ToList();
var total = await grid.ReadSingleAsync<int>();
return new PagedResult<OrderLineRow>(items, total, pageIndex, pageSize);
}
}
// ---------- 2) El endpoint ----------
[ApiController]
[Route("api/reports")]
public class OrderReportController(OrderReportService service) : ControllerBase
{
[HttpGet("order-lines")]
public async Task<ActionResult<PagedResult<OrderLineRow>>> GetOrderLines(
[FromQuery] DateTime fromDate,
[FromQuery] DateTime toExclusive,
[FromQuery] int pageIndex = 0,
[FromQuery] int pageSize = 20)
{
if (toExclusive <= fromDate)
return BadRequest("El rango de fechas no es valido.");
pageIndex = Math.Max(0, pageIndex);
pageSize = Math.Clamp(pageSize, 1, 100);
var result = await service.GetOrderLinesAsync(fromDate, toExclusive, pageIndex, pageSize);
return Ok(result);
}
}
// ---------- Tipos (si no me los dan, los defino yo) ----------
public record OrderLineRow(
int OrderLineId, int OrderId, DateTime OrderDate,
string ProductName, int Quantity, decimal UnitPrice, decimal LineTotal);
public record PagedResult<T>(IReadOnlyList<T> Items, int Total, int PageIndex, int PageSize);
NEWCO LLC es la razón social; la marca es Vita Tienda de Cocó March (VitaTienda): tienda en línea de salud, bienestar y cuidado personal — suplementos, vitaminas, probióticos, kits, skincare, limpieza natural. Cara de marca: Dra. Cocó March (naturópata; las fórmulas se presentan como diseñadas por ella; claim de marketing "más de 1 millón de mujeres").
Mercado: hispano de EE. UU. + Latinoamérica. Operación bilingüe español/inglés, atención toda la semana. Es una empresa de e-commerce con equipo de desarrollo interno — no una empresa de tecnología. Footprint pequeño (12 seguidores en Computrabajo, 5 vacantes, sin reseñas de empleados).
store.dracocomarch.com/es/… → patrón de URL con id numérico = PrestaShop.tienda.dracocomarch.com/en-us/collections/… → Shopify (mercado /en-us).Lectura: lo más probable es que estén unificando PrestaShop + Shopify + Amazon detrás de un backend .NET propio (catálogo / inventario / pedidos como fuente de verdad), con Stripe para pago. "Integrar nuevos proveedores externos tomando ownership de toda la capa (back + front)" describe exactamente ese trabajo. Confírmalo preguntando (ver Preguntas → tus preguntas).
| Área | Tecnología | Confianza |
|---|---|---|
| Frontend | Angular 20.3, TypeScript, RxJS, Angular Material, Bootstrap | Alta (JD + notas) |
| Frontend arquitectura | Microfrontends con Native Federation, federation.manifest.json | Alta (notas) |
| Testing frontend | Karma/Jasmine y Jest | Alta (notas) |
| Backend | .NET / ASP.NET, REST API | Alta (JD) |
| Acceso a datos | SQL Server (+ MySQL), escrito a mano — probablemente Dapper o ADO.NET, no EF pesado | Media — preguntar cuál |
| Cloud / CI | Azure, Azure DevOps (pipelines + boards) | Alta (JD) |
| Integraciones | Shopify Admin API, Amazon SP-API, Stripe | Alta (JD + notas) |
"Calidad, servicio cercano al cliente, experiencia de compra confiable, innovación en producto, excelencia en servicio." Traducción práctica: equipo chico, customer-obsessed, no romper producción (18 módulos vivos), alto impacto individual, ownership de punta a punta.
Sin datos públicos de LinkedIn (empresa no indexada). Espera 1–2 personas: quien redactó las notas (perfil técnico senior / tech lead, escribe "guion" y "pruebas técnicas" — lleva el proceso) y posiblemente alguien de producto u operación. Trata las notas como si vinieran del tech lead que decidirá.
"Escribe a mano un join de tres tablas con filtro por fecha y paginación, y explica la transacción." Practica hasta que salga sin pensar. Abajo: el modelo, luego cada concepto que te van a picar.
CREATE TABLE dbo.Customer (
CustomerId INT IDENTITY PRIMARY KEY,
FullName NVARCHAR(120) NOT NULL,
Email NVARCHAR(160) NOT NULL
);
CREATE TABLE dbo.Product (
ProductId INT IDENTITY PRIMARY KEY,
Sku VARCHAR(32) NOT NULL UNIQUE,
Name NVARCHAR(160) NOT NULL,
Stock INT NOT NULL CONSTRAINT CK_Product_Stock CHECK (Stock >= 0)
);
CREATE TABLE dbo.[Order] (
OrderId INT IDENTITY PRIMARY KEY,
CustomerId INT NOT NULL REFERENCES dbo.Customer(CustomerId),
OrderDate DATETIME2(0) NOT NULL,
Status VARCHAR(20) NOT NULL
);
CREATE TABLE dbo.OrderLine (
OrderLineId INT IDENTITY PRIMARY KEY,
OrderId INT NOT NULL REFERENCES dbo.[Order](OrderId),
ProductId INT NOT NULL REFERENCES dbo.Product(ProductId),
Quantity INT NOT NULL,
UnitPrice DECIMAL(10,2) NOT NULL
);
-- índices que justifican el WHERE y el ORDER BY
CREATE INDEX IX_Order_Date ON dbo.[Order](OrderDate DESC, OrderId DESC) INCLUDE (CustomerId, Status);
CREATE INDEX IX_OrderLine_Order ON dbo.OrderLine(OrderId);
CREATE INDEX IX_OrderLine_Product ON dbo.OrderLine(ProductId);
-- Renglones de pedido de un rango de fechas, con cliente y producto, paginado.
SELECT
o.OrderId,
o.OrderDate,
c.FullName AS CustomerName,
p.Sku,
p.Name AS ProductName,
ol.Quantity,
ol.UnitPrice,
ol.Quantity * ol.UnitPrice AS LineTotal
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
INNER JOIN dbo.Customer AS c ON c.CustomerId = o.CustomerId
INNER JOIN dbo.Product AS p ON p.ProductId = ol.ProductId
WHERE o.OrderDate >= @FromDate
AND o.OrderDate < @ToDateExclusive -- intervalo semiabierto: NO uses BETWEEN
ORDER BY o.OrderDate DESC, o.OrderId DESC, ol.OrderLineId DESC -- orden determinista
OFFSET (@PageIndex * @PageSize) ROWS
FETCH NEXT @PageSize ROWS ONLY;
>= @From AND < @ToExclusive en vez de BETWEEN: si OrderDate tiene hora, BETWEEN '2026-01-01' AND '2026-01-31' pierde casi todo el día 31. Señal senior inmediata.CAST(o.OrderDate AS date) = …) — mata el uso del índice. Filtra contra la columna cruda.OFFSET/FETCH exige ORDER BY, y si el orden no es único la paginación repite o se salta filas entre páginas. Siempre termina con una columna única de desempate (PK).@PageIndex base 0.@FromDate), nunca concatenar strings: inyección SQL + reutilización de plan en caché.IX_Order_Date (OrderDate DESC, OrderId DESC) cubre filtro + orden; los FK de OrderLine evitan scans en los joins.LEFT JOIN dbo.Product. Decláralo como decisión, no por descuido.-- Opción A: dos queries en el mismo request (recomendado para páginas profundas)
SELECT COUNT(*)
FROM dbo.OrderLine ol
JOIN dbo.[Order] o ON o.OrderId = ol.OrderId
WHERE o.OrderDate >= @FromDate AND o.OrderDate < @ToDateExclusive;
-- Opción B: en la misma query con ventana (cuesta un scan del set filtrado)
SELECT ..., COUNT(*) OVER() AS TotalRows
FROM ... WHERE ... ORDER BY ... OFFSET ... FETCH ...;
OFFSET grande es lento: el motor lee y descarta N filas. Para "cargar más" infinito o páginas profundas, keyset:
-- pasas el último (OrderDate, OrderId) de la página anterior
WHERE o.OrderDate >= @FromDate AND o.OrderDate < @ToDateExclusive
AND (o.OrderDate < @LastDate
OR (o.OrderDate = @LastDate AND o.OrderId < @LastOrderId))
ORDER BY o.OrderDate DESC, o.OrderId DESC
OFFSET 0 ROWS FETCH NEXT @PageSize ROWS ONLY;
Ventaja: coste constante por página, usa el índice directo. Desventaja: no puedes "saltar a la página 47".
Lo más probable: una escritura de varios pasos (crear pedido + renglones + descontar stock), que es justo el tipo de operación de un POS / recepción electrónica.
CREATE PROCEDURE dbo.CreateOrder
@CustomerId INT,
@Lines dbo.OrderLineTvp READONLY -- table-valued parameter
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON; -- error en runtime => rollback automático, sin transacciones "doomed"
BEGIN TRY
BEGIN TRANSACTION;
INSERT INTO dbo.[Order] (CustomerId, OrderDate, Status)
VALUES (@CustomerId, SYSUTCDATETIME(), 'Pending');
DECLARE @OrderId INT = SCOPE_IDENTITY();
INSERT INTO dbo.OrderLine (OrderId, ProductId, Quantity, UnitPrice)
SELECT @OrderId, l.ProductId, l.Quantity, l.UnitPrice
FROM @Lines AS l;
-- descuento de stock con bloqueo, en una sola sentencia (evita race read-then-write)
UPDATE p WITH (ROWLOCK)
SET p.Stock = p.Stock - l.Quantity
FROM dbo.Product AS p
JOIN @Lines AS l ON l.ProductId = p.ProductId;
-- el CHECK (Stock >= 0) ya protege; validación explícita para mensaje claro
IF EXISTS (SELECT 1 FROM dbo.Product p JOIN @Lines l ON l.ProductId = p.ProductId WHERE p.Stock < 0)
THROW 50001, 'Stock insuficiente para uno o más productos.', 1;
COMMIT TRANSACTION;
SELECT @OrderId AS OrderId;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW; -- re-lanza; la capa .NET registra y traduce
END CATCH
END
| Concepto | Qué decir |
|---|---|
| ACID | Atomicidad (los 3 pasos se confirman juntos o ninguno), Consistencia (stock nunca negativo — CHECK + validación), Aislamiento (nivel configurable), Durabilidad (una vez COMMIT, sobrevive a caída). |
SET XACT_ABORT ON | Con esto, un error de runtime aborta y revierte toda la transacción automáticamente y evita dejar una transacción abierta/"doomed". Va al inicio del proc. Señal senior. |
XACT_STATE() / @@TRANCOUNT | Comprobar en el CATCH antes de hacer ROLLBACK. XACT_STATE() = -1 = transacción no confirmable, solo rollback. |
| Niveles de aislamiento | Default READ COMMITTED. Para "leer stock y luego actualizar" el riesgo es lost update; se resuelve haciendo el descuento en una sola UPDATE (arriba), o con hints UPDLOCK, HOLDLOCK, o SERIALIZABLE. Menciona READ COMMITTED SNAPSHOT (RCSI) para reducir bloqueos de lectura. |
| Deadlocks | Orden de acceso consistente a las tablas en todos los procs; reintento en error 1205 desde la capa de aplicación (Polly). |
| Concurrencia optimista | Columna rowversion comprobada en el WHERE del UPDATE; si afecta 0 filas, alguien más ya cambió el registro. En EF Core: atributo [Timestamp]. |
| Transacciones cortas | Nada de llamadas HTTP dentro de la transacción. El cobro a Stripe / la llamada a Shopify va fuera; se coordina con idempotency key + patrón outbox / saga. Esto conecta directo con su trabajo de integraciones — dilo tú. |
public async Task<IReadOnlyList<OrderLineRow>> GetLinesAsync(
DateTime fromDate, DateTime toExclusive, int pageIndex, int pageSize, CancellationToken ct)
{
const string sql = @"
SELECT o.OrderId, o.OrderDate, c.FullName AS CustomerName,
p.Sku, p.Name AS ProductName, ol.Quantity, ol.UnitPrice
FROM dbo.OrderLine ol
JOIN dbo.[Order] o ON o.OrderId = ol.OrderId
JOIN dbo.Customer c ON c.CustomerId = o.CustomerId
JOIN dbo.Product p ON p.ProductId = ol.ProductId
WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive
ORDER BY o.OrderDate DESC, o.OrderId DESC, ol.OrderLineId DESC
OFFSET (@pageIndex * @pageSize) ROWS FETCH NEXT @pageSize ROWS ONLY;";
await using var conn = new SqlConnection(_connString);
var cmd = new CommandDefinition(sql,
new { fromDate, toExclusive, pageIndex, pageSize }, cancellationToken: ct);
var rows = await conn.QueryAsync<OrderLineRow>(cmd);
return rows.AsList();
}
// transacción con Dapper
await using var conn = new SqlConnection(_connString);
await conn.OpenAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);
try
{
var orderId = await conn.ExecuteScalarAsync<int>(insertOrderSql, param, tx);
await conn.ExecuteAsync(insertLinesSql, lines, tx);
await conn.ExecuteAsync(updateStockSql, lines, tx);
await tx.CommitAsync(ct);
}
catch { await tx.RollbackAsync(ct); throw; }
await using var conn = new SqlConnection(_connString);
await conn.OpenAsync(ct);
await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(ct);
await using var cmd = new SqlCommand(sql, conn, tx);
cmd.Parameters.Add("@fromDate", SqlDbType.DateTime2).Value = fromDate;
cmd.Parameters.Add("@toExclusive", SqlDbType.DateTime2).Value = toExclusive;
cmd.Parameters.Add("@pageIndex", SqlDbType.Int).Value = pageIndex;
cmd.Parameters.Add("@pageSize", SqlDbType.Int).Value = pageSize;
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct)) { /* map por índice ordinal */ }
await tx.CommitAsync(ct);
Puntos: Parameters.Add con tipo explícito (evita conversiones implícitas que rompen el índice), using/await using en todo, CancellationToken, mapear por ordinal para velocidad.
// consulta LINQ equivalente
var q = _db.OrderLines
.Where(ol => ol.Order.OrderDate >= fromDate && ol.Order.OrderDate < toExclusive)
.OrderByDescending(ol => ol.Order.OrderDate).ThenByDescending(ol => ol.OrderLineId)
.Select(ol => new OrderLineRow { /* ... */ })
.Skip(pageIndex * pageSize).Take(pageSize);
// transacción explícita
await using var tx = await _db.Database.BeginTransactionAsync(ct);
// ... SaveChangesAsync varias veces ...
await tx.CommitAsync(ct);
// SQL crudo dentro de EF
_db.Database.SqlQuery<OrderLineRow>($"SELECT ... WHERE OrderDate >= {fromDate}");
Di que EF SaveChanges ya es transaccional por llamada; la transacción explícita es para agrupar varias llamadas o mezclar con SQL crudo. Concurrencia optimista con [Timestamp] rowversion.
GROUP BY + SUM + ORDER BY + TOP.LEFT JOIN … WHERE ol.OrderLineId IS NULL o NOT EXISTS.GROUP BY c.CustomerId, DATEFROMPARTS(YEAR(o.OrderDate), MONTH(o.OrderDate), 1).HAVING COUNT(*) > 3.CreateOrder completo, probando el rollback (mete un producto con stock 1 y pide 5).La versión "nunca toqué una base de datos": montar la BD, las tablas, meter datos, y entender el JOIN y la transacción línea por línea. Complementa la pestaña SQL (decide), no la reemplaza.
localhost\SQLEXPRESS (tu equipo usa instancia con nombre; un servidor default sería localhost a secas) → Authentication: Windows Authentication → marca Trust server certificate → Connect.GO no es SQL: es una señal para el editor que dice "manda lo de arriba y sigue". Se usa entre bloques.
Una base de datos es una carpeta que agrupa tablas relacionadas. Primero la creas, luego le dices al editor que trabaje dentro de ella.
CREATE DATABASE PruebaTienda;
GO
USE PruebaTienda; -- "de aquí en adelante, todo va dentro de PruebaTienda"
GO
Sin USE, tus tablas caerían en master (la de sistema) — eso está mal. Cada ventana nueva, empieza con USE PruebaTienda;.
Una tabla es una hoja de Excel: filas y columnas. Cada columna tiene un tipo:
| Tipo | Guarda | Ejemplo |
|---|---|---|
INT | enteros | 42 |
NVARCHAR(120) | texto hasta 120 chars (la N = acentos, ñ, emojis) | 'Ana López' |
VARCHAR(32) | texto simple hasta 32 | 'SKU-001' |
DECIMAL(10,2) | 10 dígitos, 2 decimales | 15.00 |
DATETIME2(0) | fecha y hora, sin fracciones de segundo | '2026-01-05 14:30:00' |
Y las reglas que le pones a una columna:
| Regla | Qué hace |
|---|---|
PRIMARY KEY | la columna que identifica cada fila de forma única. No se repite, no va vacía. |
IDENTITY | "numérame tú solo": 1, 2, 3… Nunca escribes ese valor a mano. |
NOT NULL | obligatoria, no puede quedar vacía. |
UNIQUE | no permite dos filas con el mismo valor. |
CHECK (condición) | regla de negocio (ej: stock nunca negativo). |
REFERENCES otraTabla(col) | llave foránea (FK): este valor tiene que existir en la otra tabla. |
El orden importa por las FK: no puedes crear una tabla que apunta a otra que aún no existe. Customer y Product primero; Order depende de Customer; OrderLine depende de las dos.
CREATE TABLE dbo.Customer (
CustomerId INT IDENTITY PRIMARY KEY, -- id autonumerado + PK
FullName NVARCHAR(120) NOT NULL,
Email NVARCHAR(160) NOT NULL
);
CREATE TABLE dbo.Product (
ProductId INT IDENTITY PRIMARY KEY,
Sku VARCHAR(32) NOT NULL UNIQUE,
Name NVARCHAR(160) NOT NULL,
Stock INT NOT NULL
CONSTRAINT CK_Product_Stock CHECK (Stock >= 0) -- nombre a la regla
);
CREATE TABLE dbo.[Order] ( -- corchetes: ORDER es palabra reservada
OrderId INT IDENTITY PRIMARY KEY,
CustomerId INT NOT NULL REFERENCES dbo.Customer(CustomerId), -- FK
OrderDate DATETIME2(0) NOT NULL,
Status VARCHAR(20) NOT NULL
);
CREATE TABLE dbo.OrderLine ( -- el detalle: qué productos y cuántos
OrderLineId INT IDENTITY PRIMARY KEY,
OrderId INT NOT NULL REFERENCES dbo.[Order](OrderId),
ProductId INT NOT NULL REFERENCES dbo.Product(ProductId),
Quantity INT NOT NULL,
UnitPrice DECIMAL(10,2) NOT NULL -- precio congelado al momento de la venta
);
-- índices: como el índice de un libro, para ir directo en vez de leer todo
CREATE INDEX IX_Order_Date ON dbo.[Order](OrderDate DESC, OrderId DESC) INCLUDE (CustomerId, Status);
CREATE INDEX IX_OrderLine_Order ON dbo.OrderLine(OrderId);
CREATE INDEX IX_OrderLine_Product ON dbo.OrderLine(ProductId);
GO
Un pedido (Order) tiene muchos renglones (OrderLine). Pedido #1 = "compra de Ana"; sus renglones = "2 Vitamina C" + "1 Probiótico".
INSERT INTO tabla (columnas) VALUES (valores). No pones el Id porque es IDENTITY (se autonumera).
INSERT INTO dbo.Customer (FullName, Email) VALUES
('Ana López','ana@mail.com'),('Beto Ruiz','beto@mail.com'),('Carmen Díaz','carmen@mail.com');
-- fíjate en los stocks bajos: sirven para probar fallos después
INSERT INTO dbo.Product (Sku, Name, Stock) VALUES
('SKU-001','Vitamina C',100),('SKU-002','Probiótico',50),('SKU-003','Omega 3',3),('SKU-004','Colágeno',0);
-- fechas repartidas en enero y febrero 2026 A PROPÓSITO
INSERT INTO dbo.[Order] (CustomerId, OrderDate, Status) VALUES
(1,'2026-01-05 10:00:00','Paid'),
(2,'2026-01-20 16:30:00','Paid'),
(1,'2026-02-03 09:15:00','Pending'), -- cae FUERA de un filtro de enero
(3,'2026-01-28 12:00:00','Paid');
INSERT INTO dbo.OrderLine (OrderId, ProductId, Quantity, UnitPrice) VALUES
(1,1,2,15.00),(1,2,1,25.00),(2,3,1,30.00),(3,1,5,15.00),(4,2,3,25.00),(4,1,1,15.00);
GO
-- verifica que quedó todo
SELECT * FROM dbo.Customer;
SELECT * FROM dbo.Product;
SELECT * FROM dbo.[Order];
SELECT * FROM dbo.OrderLine;
Qué es un JOIN: cada tabla guarda un pedacito. El pedido sabe el id del cliente, no su nombre. Un JOIN "pega" las filas de varias tablas usando la columna que coincide.
SELECT
o.OrderId,
o.OrderDate,
c.FullName AS CustomerName, -- AS renombra la columna en el resultado
p.Sku,
p.Name AS ProductName,
ol.Quantity,
ol.UnitPrice,
ol.Quantity * ol.UnitPrice AS LineTotal -- columna calculada al vuelo
FROM dbo.OrderLine AS ol -- tabla base; "ol" es un apodo (alias)
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId -- pega Order donde el OrderId casa
INNER JOIN dbo.Customer AS c ON c.CustomerId = o.CustomerId -- pega el cliente
INNER JOIN dbo.Product AS p ON p.ProductId = ol.ProductId -- pega el producto
WHERE o.OrderDate >= '2026-01-01'
AND o.OrderDate < '2026-02-01' -- rango semiabierto (ver abajo)
ORDER BY o.OrderDate DESC, o.OrderId DESC, ol.OrderLineId DESC -- orden determinista
OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY; -- "salta 0, dame las siguientes 5"
INNER JOIN = solo filas que casan en ambos lados. LEFT JOIN = todas las de la izquierda aunque no haya pareja a la derecha.
BETWEEN. OrderDate tiene hora. BETWEEN '2026-01-01' AND '2026-01-31' significa <= '2026-01-31 00:00:00' y pierde todo el día 31. Correcto: >= inicio AND < inicio_del_siguiente_mes.WHERE CAST(o.OrderDate AS date) = … anula el índice. Filtra contra la columna cruda. La palabra elegante: predicado SARGable.OFFSET/FETCH obliga a ORDER BY. Si el orden no es único, la página 2 repite o se salta filas. Termina siempre con una columna única (PK) de desempate.-- fórmula: OFFSET = numeroDePagina * tamañoDePagina (la página empieza en 0)
... OFFSET 0 ROWS FETCH NEXT 2 ROWS ONLY; -- página 1
... OFFSET 2 ROWS FETCH NEXT 2 ROWS ONLY; -- página 2
... OFFSET 4 ROWS FETCH NEXT 2 ROWS ONLY; -- página 3
En código nunca pegas valores en el texto (inyección SQL + rendimiento). Usas variables con @:
DECLARE @FromDate DATETIME2(0) = '2026-01-01';
DECLARE @ToDateExclusive DATETIME2(0) = '2026-02-01';
DECLARE @PageIndex INT = 0;
DECLARE @PageSize INT = 5;
SELECT o.OrderId, o.OrderDate, c.FullName AS CustomerName,
p.Sku, p.Name AS ProductName, ol.Quantity, ol.UnitPrice,
ol.Quantity * ol.UnitPrice AS LineTotal
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
INNER JOIN dbo.Customer AS c ON c.CustomerId = o.CustomerId
INNER JOIN dbo.Product AS p ON p.ProductId = ol.ProductId
WHERE o.OrderDate >= @FromDate
AND o.OrderDate < @ToDateExclusive
ORDER BY o.OrderDate DESC, o.OrderId DESC, ol.OrderLineId DESC
OFFSET (@PageIndex * @PageSize) ROWS
FETCH NEXT @PageSize ROWS ONLY;
Qué es: un grupo de operaciones que pasan todas o ninguna. Analogía: transferencia bancaria = "restar de A" + "sumar a B". Si se cae la luz entre las dos, el dinero desaparece. La transacción lo evita.
Crear un pedido = 3 pasos: (1) insertar en Order, (2) insertar sus renglones, (3) descontar stock. Si el paso 3 falla y 1–2 ya corrieron → pedido fantasma. La transacción revierte todo.
BEGIN TRANSACTION; -- "anota todo en lápiz"
INSERT INTO dbo.[Order] (CustomerId, OrderDate, Status)
VALUES (1, SYSUTCDATETIME(), 'Pending');
DECLARE @OrderId INT = SCOPE_IDENTITY(); -- el id que se acaba de generar
INSERT INTO dbo.OrderLine (OrderId, ProductId, Quantity, UnitPrice)
VALUES (@OrderId, 1, 2, 15.00);
UPDATE dbo.Product SET Stock = Stock - 2 WHERE ProductId = 1;
COMMIT TRANSACTION; -- "pásalo a tinta, ya es permanente"
-- (si escribes ROLLBACK TRANSACTION en vez de COMMIT: borra todo, como si nada)
CREATE PROCEDURE dbo.CreateSimpleOrder
@CustomerId INT, @ProductId INT, @Quantity INT, @UnitPrice DECIMAL(10,2)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON; -- si algo revienta en runtime, revierte TODO solo
BEGIN TRY
BEGIN TRANSACTION;
INSERT INTO dbo.[Order] (CustomerId, OrderDate, Status)
VALUES (@CustomerId, SYSUTCDATETIME(), 'Pending');
DECLARE @OrderId INT = SCOPE_IDENTITY();
INSERT INTO dbo.OrderLine (OrderId, ProductId, Quantity, UnitPrice)
VALUES (@OrderId, @ProductId, @Quantity, @UnitPrice);
UPDATE dbo.Product SET Stock = Stock - @Quantity WHERE ProductId = @ProductId;
IF EXISTS (SELECT 1 FROM dbo.Product WHERE ProductId = @ProductId AND Stock < 0)
THROW 50001, 'Stock insuficiente.', 1; -- error propio (>= 50000 son tuyos)
COMMIT TRANSACTION;
SELECT @OrderId AS NuevoOrderId;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; -- ¿hay transacción viva? revertirla
THROW; -- re-lanza el error para que .NET lo registre
END CATCH
END;
GO
| Línea | Explicación de tonto |
|---|---|
SET NOCOUNT ON | "no me mandes 'X rows affected' por cada insert". Limpia ruido, ahorra tráfico. |
SET XACT_ABORT ON | el seguro de vida: si una sentencia falla, revierte la transacción entera sola. Va al inicio. |
BEGIN TRY / BEGIN CATCH | "intenta esto; si falló, ejecuta esto otro". |
THROW 50001, '...', 1 | lanzar tu propio error a mano. |
XACT_STATE() <> 0 | "¿hay una transacción abierta ahora?" Si sí, ROLLBACK. Evita revertir algo que ya no existe. |
THROW; (sin argumentos) | re-lanza el error original hacia arriba, para que tu código C# lo loguee. |
-- Omega 3 (ProductId 3) tiene stock 3. Pide 5:
EXEC dbo.CreateSimpleOrder @CustomerId=1, @ProductId=3, @Quantity=5, @UnitPrice=30.00;
-- => error rojo. Ahora comprueba que NO quedó nada:
SELECT * FROM dbo.[Order] WHERE CustomerId = 1 ORDER BY OrderId DESC; -- sin pedido nuevo
SELECT Stock FROM dbo.Product WHERE ProductId = 3; -- sigue en 3, no en -2
-- Una que SÍ funciona:
EXEC dbo.CreateSimpleOrder @CustomerId=1, @ProductId=1, @Quantity=4, @UnitPrice=15.00;
SELECT Stock FROM dbo.Product WHERE ProductId = 1; -- bajó de 100 a 96
Orden de práctica (Día 1): 1) monta el script completo · 2) escribe el JOIN de memoria, sin copiar, hasta que salga solo · 3) mueve el OFFSET y observa · 4) explica en voz alta por qué no usas BETWEEN · 5) escribe el proc y prueba el rollback · 6) los 7 mini-drills de la pestaña SQL (decide).
| Promise | Observable | |
|---|---|---|
| Valores | uno solo | 0..∞ (un flujo en el tiempo) |
| Ejecución | eager: corre al crearse | lazy: corre al hacer subscribe() |
| Cancelable | no | sí: unsubscribe() aborta (p. ej. cancela el HTTP) |
| Operadores | .then()/.catch() | pipe(map, filter, switchMap, debounceTime, retry, combineLatest…) |
| Sync/async | siempre async (microtask) | puede emitir sync o async |
| Multi-suscriptor | comparte el único resultado | cold: cada subscribe reejecuta; hot/share(): multicast |
En Angular: HttpClient devuelve Observable → al desuscribir cancela la petición (ideal para typeahead con switchMap). Eventos del Router, form.valueChanges, interop con Signals (toSignal(), toObservable()). El pipe async se suscribe y desuscribe solo.
Fugas de memoria = suscripciones sin gestionar. Soluciones: pipe async, takeUntilDestroyed() (Angular 16+), DestroyRef.
| Operador | Comportamiento | Caso real en un POS / backoffice |
|---|---|---|
switchMap | cancela la anterior | búsqueda de producto: solo importa la última tecla |
mergeMap | todas en paralelo | disparar N validaciones independientes a la vez |
concatMap | cola, en orden | guardar renglones de un pedido en secuencia sin pisarse |
exhaustMap | ignora nuevas mientras trabaja | botón "confirmar venta": evita doble submit |
Extra: forkJoin (espera a que todas terminen — cargar datos de arranque), combineLatest (recombina al cambiar cualquiera — filtros), catchError + retry/retryWhen, shareReplay(1) (cachear la respuesta de un GET de catálogo).
Qué es: el concepto de Module Federation reconstruido sobre ESM nativo + import maps, para que funcione con el build moderno de Angular (esbuild/Vite). Webpack Module Federation dejó de encajar cuando Angular migró a esbuild; @angular-architects/native-federation es el reemplazo. La configuración de remotos vive en federation.manifest.json (el que tienes en tu repo).
shared.NG0203 (inject fuera de contexto). Alinear shared con singleton: true y strictVersion.federation.manifest.json por entorno.Prepárate a dibujarlo: shell (host) → lee federation.manifest.json → genera import map → carga remoteEntry de cada microfrontend bajo demanda → monta su ruta. Deps compartidas resueltas una vez.
computed, efectos con effect); RxJS para flujos asíncronos y coordinación de eventos. Interop con toSignal/toObservable. No migrar todo a Signals porque sí.OnPush + inmutabilidad + async pipe; ChangeDetectorRef. Con Signals, CD granular.imports: [...] en el componente.loadComponent / loadChildren con rutas; @defer blocks para diferir render.updateOn: 'blur'.Los mismos temas de la pestaña Angular, pero con el modelo mental masticado y la frase exacta que dices en la entrevista.
| Promise | Observable | |
|---|---|---|
| ¿Cuántos valores? | uno | 0, 1 o muchos (un flujo en el tiempo) |
| ¿Cuándo arranca? | eager: al crearla ya corrió | lazy: nada hasta .subscribe() |
| ¿Cancelable? | no | sí: unsubscribe(). En HTTP cancela la petición de verdad |
| ¿Transformar? | .then() / .catch() | .pipe(map, filter, switchMap, debounceTime…) |
| ¿Sync/async? | siempre async | puede emitir sync o async |
| Varios suscriptores | comparten el único resultado | cold: cada subscribe reejecuta; hot/share(): multicast |
// HttpClient SIEMPRE devuelve Observable
this.http.get<Product[]>('/api/catalog').subscribe(list => this.products = list);
switchMap es tan limpio.form.valueChanges, eventos del Router, paramMap → todos Observables.async se suscribe y desuscribe solo: <div *ngIf="product$ | async as p">{{ p.name }}</div>Fuga = te suscribes y nunca te desuscribes; el componente se destruye pero la suscripción sigue viva consumiendo memoria. Soluciones: 1) pipe async (la preferida) · 2) takeUntilDestroyed() (v16+) · 3) DestroyRef.
"Una promesa es un valor único, eager y no cancelable. Un Observable es un flujo perezoso: no corre hasta que te suscribes, puede emitir muchos valores y lo cancelas con unsubscribe. En Angular importa porque HttpClient devuelve Observables —desuscribirte cancela la petición real— y porque tengo operadores como switchMap o debounceTime para coordinar eventos asíncronos. El riesgo es olvidar desuscribirte; lo resuelvo con el pipe async o takeUntilDestroyed."
Tienes un Observable que emite valores (cada tecla). Por cada valor quieres lanzar otro Observable (una petición HTTP). Sin operador terminas con un Observable de Observables anidado e inútil. Aplanar = fundir el interno dentro del externo para tener un solo flujo. La pregunta clave: cuando llega un valor nuevo y el anterior no terminó, ¿qué hago?
| Operador | Con el anterior… | Caso real (POS / backoffice) |
|---|---|---|
switchMap | lo cancela | búsqueda de producto: solo importa la última tecla |
mergeMap | lo deja, todos en paralelo | N validaciones independientes a la vez |
concatMap | hace cola, en orden | guardar renglones de un pedido sin que se pisen |
exhaustMap | ignora los nuevos mientras trabaja | botón "Confirmar venta": anti doble-submit |
// buscador con switchMap
results$ = this.searchControl.valueChanges.pipe(
debounceTime(300), // espera 300ms sin teclear
distinctUntilChanged(), // si el texto no cambió, no busques
switchMap(term => this.api.searchProducts(term)) // cancela la búsqueda anterior
);
Extras que conviene nombrar: forkJoin (espera a que todas terminen — datos de arranque) · combineLatest (recombina al cambiar cualquiera — filtros) · shareReplay(1) (cachear un GET de catálogo) · catchError + retry.
"Se diferencian por lo que hacen cuando llega un valor nuevo antes de terminar el anterior. switchMap cancela —búsquedas—; mergeMap paraleliza; concatMap encola en orden —guardados secuenciales—; exhaustMap ignora los nuevos —botón de confirmar, anti doble submit."
En vez de una app Angular gigante, tienes varias apps pequeñas que se cargan al vuelo. El shell (host) es el marco: sidebar, header, router. Los remotos son los cuadros que cuelgas: "módulo de compras", "monitor de embarques". Cada uno en su repo y su deploy.
1. El shell arranca
2. Lee federation.manifest.json -> { "compras": "https://.../remoteEntry.json", ... }
3. Genera un "import map" (mapa de modulos ESM nativos del navegador)
4. El usuario navega a /compras
5. El shell descarga el remoteEntry de "compras" y monta su ruta
6. Angular, RxJS, Material se cargan UNA vez y se comparten
Por qué "Native": Module Federation vivía en Webpack. Angular migró su compilador a esbuild y Webpack MF dejó de encajar. @angular-architects/native-federation reconstruye la idea sobre ESM nativo + import maps.
NG0203. Se mitiga con shared: { singleton: true, strictVersion: true }."Es Module Federation reconstruido sobre ESM nativo para que funcione con el compilador esbuild de Angular. Me dio deploy independiente por microfrontend y dependencias compartidas. Lo que costó: alinear versiones —un remoto con otra versión de Angular te mete dos instancias y errores de DI— y que no hay comunicación entre microfrontends integrada, hay que montarla. El dev local también se complica porque necesitas todos los remotos arriba."
| Tema | Qué decir |
|---|---|
| Signals vs RxJS | Signals para estado síncrono de UI (computed, effect). RxJS para flujos asíncronos y eventos. Interop con toSignal/toObservable. No migrar todo "porque sí". |
| Change detection | Zone.js (default) vs zoneless (v18+). OnPush + inmutabilidad + pipe async. Con Signals, CD granular. |
| Standalone components | Default desde v17. Sin NgModule; dependencias en imports: [...] del componente. |
| Lazy loading | loadComponent / loadChildren en rutas. Bloques @defer para diferir render. |
| Formularios | Reactive Forms tipados (v14+), validadores async, updateOn: 'blur'. |
| Interceptores HTTP funcionales | (v15+) auth, retry, logging, y la idempotency-key de las integraciones. |
Eres el único del proceso que nombra las dos. Pregunta 3: "el último test de componente que escribiste y qué comprobaba." Necesitas una anécdota concreta y verificable. Rellena la plantilla con tu último test real.
@Output / que llamó al servicio con los args correctos / rama de error].TestBed con [imports standalone], mocks con [jasmine.createSpyObj / { provide: X, useValue }].fakeAsync+tick() / HttpTestingController / whenStable].describe('SaleButtonComponent', () => {
let fixture: ComponentFixture<SaleButtonComponent>;
let sales: jasmine.SpyObj<SalesService>;
beforeEach(async () => {
sales = jasmine.createSpyObj<SalesService>('SalesService', ['confirm']);
await TestBed.configureTestingModule({
imports: [SaleButtonComponent], // standalone
providers: [{ provide: SalesService, useValue: sales }],
}).compileComponents();
fixture = TestBed.createComponent(SaleButtonComponent);
});
it('deshabilita el botón mientras confirma', fakeAsync(() => {
sales.confirm.and.returnValue(timer(100).pipe(map(() => ({ ok: true }))));
fixture.componentInstance.total = 250;
fixture.detectChanges();
const btn = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
btn.click();
fixture.detectChanges();
expect(btn.disabled).toBeTrue(); // durante la operación
tick(100);
fixture.detectChanges();
expect(btn.disabled).toBeFalse();
expect(sales.confirm).toHaveBeenCalledOnceWith(250);
}));
});
TestBed.configureTestingModule({ imports: [HttpClientTestingModule] });
const http = TestBed.inject(HttpTestingController);
service.getCatalog().subscribe(r => expect(r.length).toBe(2));
const req = http.expectOne('/api/catalog');
expect(req.request.method).toBe('GET');
req.flush([{ id: 1 }, { id: 2 }]);
http.verify();
const loader = TestbedHarnessEnvironment.loader(fixture);
const button = await loader.getHarness(MatButtonHarness.with({ text: 'Confirmar' }));
await button.click();
const input = await loader.getHarness(MatInputHarness);
await input.setValue('ABC-123');
Ventaja: el test no depende del DOM interno de Material; sobrevive a upgrades.
| Karma/Jasmine | Jest | |
|---|---|---|
| Entorno | navegador real (Chrome headless) | jsdom (simulado en Node) |
| Velocidad | más lento (arranca navegador) | rápido, paralelo, watch mode ágil |
| Spies/mocks | jasmine.createSpy | jest.fn(), jest.mock() de módulos |
| Snapshots | no nativo | sí |
| Setup en Angular | viene por defecto | jest-preset-angular |
Por qué mantener ambos: Jest para el ciclo rápido de TDD y CI (miles de tests unitarios de lógica y componentes); Karma cuando necesitas fidelidad de navegador real — APIs del DOM que jsdom no implementa bien, layout, algunas cosas de Material/CDK, o pruebas que rozan lo e2e. Si te preguntan "¿los unificarías?": depende del coste de mantener dos configs vs. lo que se perdería; migrar a Jest todo lo unitario y dejar Karma/Playwright para lo que exige navegador es una postura defendible.
Tests de componente aislados: sin cambio. Lo difícil es la integración cruzando la frontera de federación — ahí conviene contract testing entre shell y remoto, o e2e (Playwright/Cypress) sobre el shell ensamblado.
Qué es un test, cómo se lee un spec de Angular, y cada palabra rara (TestBed, fixture, fakeAsync) traducida. Complementa la pestaña Testing.
Un test unitario = un trocito de código que verifica automáticamente que una pieza pequeña (un componente, un servicio) hace lo que debe. Se corre en cada commit; si algo se rompe, te enteras en segundos y no en producción.
Todo test tiene 3 fases (regla AAA):
describe('SaleButtonComponent', () => { // GRUPO de tests
let fixture: ComponentFixture<SaleButtonComponent>;
let sales: jasmine.SpyObj<SalesService>;
beforeEach(async () => { // ANTES DE CADA test (it)
sales = jasmine.createSpyObj<SalesService>('SalesService', ['confirm']); // servicio falso
await TestBed.configureTestingModule({ // arma un mini-Angular para el test
imports: [SaleButtonComponent], // el componente es standalone
providers: [{ provide: SalesService, useValue: sales }], // usa el falso, no el real
}).compileComponents();
fixture = TestBed.createComponent(SaleButtonComponent); // instancia + su DOM
});
it('deshabilita el boton mientras confirma', () => { /* AAA aqui */ });
});
| Palabra | Qué es |
|---|---|
describe('X', () => {}) | una carpeta de tests. Se puede anidar. |
it('hace Y', () => {}) | un test. Léelo como frase: "it deshabilita el botón mientras confirma". |
beforeEach | código que corre antes de cada it, para empezar limpio. |
TestBed | el "banco de pruebas": un Angular en miniatura configurado solo con lo necesario. |
fixture | envuelve la instancia del componente y su DOM. |
fixture.componentInstance | la clase (propiedades y métodos). |
fixture.nativeElement | el HTML renderizado (para querySelector, leer texto). |
fixture.detectChanges() | corre el change detection: aplica los cambios de datos al DOM. Sin esto el HTML no se actualiza. |
Un test unitario prueba una pieza. Si el componente usa SalesService (que llama a una API real), no quieres que el test llame a la API. Le pones un doble.
const sales = jasmine.createSpyObj<SalesService>('SalesService', ['confirm']);
sales.confirm.and.returnValue(of({ ok: true })); // "cuando te llamen, devuelve esto"
// ... despues de actuar ...
expect(sales.confirm).toHaveBeenCalledOnceWith(250); // "1 vez, con el argumento 250"
En Jest es lo mismo con jest.fn() y jest.spyOn().
| Assertion | Verifica |
|---|---|
expect(x).toBe(y) | igualdad estricta (===) — números, strings, misma referencia |
expect(x).toEqual(y) | igualdad profunda — objetos/arrays por contenido |
expect(x).toBeTrue() / toBeFalse() | booleanos exactos |
expect(fn).toHaveBeenCalledWith(a, b) | que un spy fue llamado con esos argumentos |
expect(() => fn()).toThrow() | que lanza un error |
it('deshabilita el boton mientras confirma', fakeAsync(() => {
// ARRANGE: el servicio falso devuelve un resultado que tarda 100ms
sales.confirm.and.returnValue(timer(100).pipe(map(() => ({ ok: true }))));
fixture.componentInstance.total = 250;
fixture.detectChanges(); // pinta el estado inicial
// ACT: el usuario da clic
const btn = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
btn.click();
fixture.detectChanges();
// ASSERT 1: mientras corre, el boton esta deshabilitado
expect(btn.disabled).toBeTrue();
tick(100); // avanza el reloj falso 100ms de golpe
fixture.detectChanges();
// ASSERT 2 y 3
expect(btn.disabled).toBeFalse();
expect(sales.confirm).toHaveBeenCalledOnceWith(250);
}));
fakeAsync + tick() — controlar el tiempoCódigo async (un setTimeout, un timer, un debounceTime) no termina "ahora mismo". fakeAsync congela el reloj; tick(100) lo avanza 100ms de golpe y ejecuta lo que estuviera programado; flush() avanza hasta que no quede nada. Alternativa para promesas: async + await fixture.whenStable().
HttpTestingController — HTTP sin redTestBed.configureTestingModule({ imports: [HttpClientTestingModule] });
const http = TestBed.inject(HttpTestingController);
service.getCatalog().subscribe(r => expect(r.length).toBe(2)); // te suscribes
const req = http.expectOne('/api/catalog'); // verifica que se hizo UNA peticion a esa URL
expect(req.request.method).toBe('GET');
req.flush([{ id: 1 }, { id: 2 }]); // TU decides que "responde el servidor"
http.verify(); // no quedaron peticiones sin atender
No hay servidor: interceptas la petición, verificas cómo se hizo, y tú das la respuesta.
const loader = TestbedHarnessEnvironment.loader(fixture);
const button = await loader.getHarness(MatButtonHarness.with({ text: 'Confirmar' }));
await button.click();
const input = await loader.getHarness(MatInputHarness);
await input.setValue('ABC-123');
Ventaja: el test interactúa con la API pública del componente, no con su HTML interno. Sobrevive a los upgrades de Material.
| Karma / Jasmine | Jest | |
|---|---|---|
| Dónde corre | Chrome headless — navegador real | jsdom — navegador simulado en Node |
| Velocidad | más lento (arranca Chrome) | rápido, paralelo, --watch ágil |
| Spies/mocks | jasmine.createSpy | jest.fn(), jest.mock() de módulos |
| Snapshots | no nativo | sí |
| Setup en Angular | por defecto | jest-preset-angular |
Por qué ambos: Jest para el ciclo rápido de TDD y CI (miles de tests en segundos); Karma cuando necesitas fidelidad de navegador real (APIs del DOM que jsdom implementa mal, layout, ciertas cosas de Material/CDK).
"¿Los unificarías?" "Depende del costo de mantener dos configs vs. lo que se perdería. Postura defendible: migrar todo lo unitario a Jest y dejar Karma o Playwright solo para lo que exige navegador real. Pero primero mediría cuántos tests dependen de comportamiento real de navegador."
@Output / que llamó al servicio con los args correctos / rama de error.TestBed con imports standalone, mock con jasmine.createSpyObj.fakeAsync+tick() / HttpTestingController.Ejemplo hablado: "El último fue para un componente de confirmación de recepción. Cambié la lógica para deshabilitar el botón mientras la petición está en vuelo, para evitar doble submit. El test montaba el componente con TestBed, mockeaba el servicio con un spy que devolvía un observable con delay, hacía clic, verificaba btn.disabled === true, avanzaba el reloj con tick, y comprobaba que el servicio se llamó una sola vez. Usé fakeAsync."
Tests de componente aislados: sin cambio. Lo difícil es la integración cruzando la frontera de federación (shell ↔ remoto): contract testing entre shell y remoto, o e2e (Playwright/Cypress) sobre el shell ya ensamblado.
Drill (Día 3): 1) CounterComponent con botón que incrementa y emite un @Output cada 5 — test de valor inicial, de clic, y de emisión (spy del EventEmitter) · 2) CatalogService.getProducts() con HttpTestingController (expectOne, flush, verify) · 3) cualquier componente con setTimeout/debounceTime probado con fakeAsync+tick.
SQL Server → API .NET (con POST/PUT/DELETE + validación + tests) → Angular 20 + Material con una pantalla para validar el back y una tabla paginada. Móntala antes de la entrevista; en la prueba solo la extiendes. Cada comando está listo para copiar.
Objetivo: llegar con esto ya corriendo. Si el ejercicio es construir desde cero, partes de una base que compila y tiene tests verdes — sin pelear con dotnet new ni con CORS bajo presión.
SQL Server API .NET 9 Angular 20 + Material
PruebaTienda <--> Web API / Dapper <--> pantalla CRUD +
tabla Product /api/products tabla paginada
appsettings.json proxy.conf.json + CORS
"conectar la base con el back" "conectar el back con el front"
Usamos la tabla Product de la pestaña SQL · paso a paso (tiene CHECK (Stock >= 0) y UNIQUE en Sku — perfecta para enseñar validación).
dotnet --info # SDK 8 o 9
node -v # LTS (20+)
npm i -g @angular/cli # CLI de Angular
ng version # ~20.x
sqlcmd -S "localhost\SQLEXPRESS" -E -C -Q "SELECT name FROM sys.databases" # ¿está PruebaTienda?
Si PruebaTienda no existe, corre el script de la pestaña SQL · paso a paso primero.
| Extensión | Para qué |
|---|---|
ms-dotnettools.csdevkit (C# Dev Kit) | IntelliSense, debug y test runner de .NET |
angular.ng-template (Angular Language Service) | autocompletado en templates de Angular |
humao.rest-client | ejecutar peticiones desde un archivo .http (sin Postman) |
ms-mssql.mssql | correr SQL contra SQL Server desde el editor |
editorconfig.editorconfig | estilo de código consistente |
prueba-newco/
├─ .vscode/ (tasks, launch, settings, extensions)
├─ db/ (schema.sql, seed.sql, reset.ps1)
├─ backend/
│ ├─ Prueba.Api/ (la Web API)
│ └─ Prueba.Api.Tests/ (xUnit)
├─ frontend/ (Angular, lo crea "ng new")
├─ package.json (scripts que arrancan todo junto)
└─ README.md
mkdir prueba-newco && cd prueba-newco
mkdir .vscode db backend
cd backend
dotnet new sln -n Prueba
dotnet new webapi -n Prueba.Api --use-controllers # API con controllers
dotnet new xunit -n Prueba.Api.Tests
dotnet sln add Prueba.Api Prueba.Api.Tests
dotnet add Prueba.Api.Tests reference Prueba.Api
# paquetes del API
dotnet add Prueba.Api package Dapper
dotnet add Prueba.Api package Microsoft.Data.SqlClient
dotnet add Prueba.Api package Swashbuckle.AspNetCore
# paquetes de test
dotnet add Prueba.Api.Tests package Microsoft.AspNetCore.Mvc.Testing --version "9.0.*"
dotnet add Prueba.Api.Tests package FluentAssertions
En backend/Prueba.Api/appsettings.Development.json:
{
"ConnectionStrings": {
"Sql": "Server=localhost\\SQLEXPRESS;Database=PruebaTienda;Trusted_Connection=True;TrustServerCertificate=True"
}
}
localhost\\SQLEXPRESS (doble barra: es JSON) porque tu SQL Server es una instancia con nombre. Trusted_Connection=True = login de Windows. TrustServerCertificate=True evita el error de certificado en local.
using System.Data;
using Microsoft.Data.SqlClient;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// una conexión SQL por request
builder.Services.AddScoped<IDbConnection>(_ =>
new SqlConnection(builder.Configuration.GetConnectionString("Sql")));
builder.Services.AddScoped<ProductRepository>();
// CORS: dejar entrar al Angular de desarrollo
builder.Services.AddCors(o => o.AddPolicy("dev", p =>
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors("dev");
app.MapControllers();
app.Run();
public partial class Program { } // para que los tests puedan referenciarlo
using System.ComponentModel.DataAnnotations;
public record ProductDto(int ProductId, string Sku, string Name, int Stock);
public class ProductInput
{
[Required, StringLength(32, MinimumLength = 3)]
public string Sku { get; set; } = "";
[Required, StringLength(160, MinimumLength = 2)]
public string Name { get; set; } = "";
[Range(0, int.MaxValue, ErrorMessage = "El stock no puede ser negativo.")]
public int Stock { get; set; }
}
public record PagedResult<T>(IReadOnlyList<T> Items, int Total, int PageIndex, int PageSize);
Los atributos [Required], [Range], etc. hacen que el API devuelva 400 con los errores si el body no cumple — sin escribir un solo if.
using System.Data;
using Dapper;
public class ProductRepository(IDbConnection db)
{
public async Task<PagedResult<ProductDto>> GetPagedAsync(int pageIndex, int pageSize)
{
const string sql = @"
SELECT ProductId, Sku, Name, Stock
FROM dbo.Product
ORDER BY ProductId DESC
OFFSET (@pageIndex * @pageSize) ROWS FETCH NEXT @pageSize ROWS ONLY;
SELECT COUNT(*) FROM dbo.Product;";
using var multi = await db.QueryMultipleAsync(sql, new { pageIndex, pageSize });
var items = (await multi.ReadAsync<ProductDto>()).AsList();
var total = await multi.ReadSingleAsync<int>();
return new PagedResult<ProductDto>(items, total, pageIndex, pageSize);
}
public Task<ProductDto?> GetByIdAsync(int id) => db.QuerySingleOrDefaultAsync<ProductDto>(
"SELECT ProductId, Sku, Name, Stock FROM dbo.Product WHERE ProductId = @id", new { id });
public Task<int> CreateAsync(ProductInput p) => db.ExecuteScalarAsync<int>(@"
INSERT INTO dbo.Product (Sku, Name, Stock) VALUES (@Sku, @Name, @Stock);
SELECT CAST(SCOPE_IDENTITY() AS int);", p);
public Task<int> UpdateAsync(int id, ProductInput p) => db.ExecuteAsync(@"
UPDATE dbo.Product SET Sku = @Sku, Name = @Name, Stock = @Stock
WHERE ProductId = @id", new { id, p.Sku, p.Name, p.Stock });
public Task<int> DeleteAsync(int id) => db.ExecuteAsync(
"DELETE FROM dbo.Product WHERE ProductId = @id", new { id });
}
Update y Delete devuelven el número de filas afectadas → 0 = "no existe" → el controller responde 404.
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/products")]
public class ProductsController(ProductRepository repo) : ControllerBase
{
// GET /api/products?pageIndex=0&pageSize=10
[HttpGet]
public async Task<ActionResult<PagedResult<ProductDto>>> GetPaged(int pageIndex = 0, int pageSize = 10)
=> Ok(await repo.GetPagedAsync(pageIndex, Math.Clamp(pageSize, 1, 100)));
// GET /api/products/5
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id)
=> await repo.GetByIdAsync(id) is { } p ? Ok(p) : NotFound();
// POST /api/products
[HttpPost]
public async Task<ActionResult<ProductDto>> Create(ProductInput input)
{
var id = await repo.CreateAsync(input);
var created = await repo.GetByIdAsync(id);
return CreatedAtAction(nameof(GetById), new { id }, created); // 201 + header Location
}
// PUT /api/products/5
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, ProductInput input)
=> await repo.UpdateAsync(id, input) == 0 ? NotFound() : NoContent(); // 204
// DELETE /api/products/5
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
=> await repo.DeleteAsync(id) == 0 ? NotFound() : NoContent(); // 204
}
| Verbo | Éxito | No existe | Body inválido |
|---|---|---|---|
| GET lista | 200 + PagedResult | — | — |
| GET por id | 200 + producto | 404 | — |
| POST | 201 + Location | — | 400 + errores |
| PUT | 204 (sin body) | 404 | 400 |
| DELETE | 204 | 404 | — |
dotnet run --project backend/Prueba.Api
# abre https://localhost:7xxx/swagger → prueba cada endpoint desde ahí
O crea backend/Prueba.Api/Prueba.Api.http y ejecútalo con REST Client (clic en "Send Request"):
@base = https://localhost:7xxx
### listar (paginado)
GET {{base}}/api/products?pageIndex=0&pageSize=5
### crear
POST {{base}}/api/products
Content-Type: application/json
{ "sku": "SKU-777", "name": "Magnesio", "stock": 20 }
### crear invalido (debe dar 400)
POST {{base}}/api/products
Content-Type: application/json
{ "sku": "x", "name": "", "stock": -3 }
### actualizar
PUT {{base}}/api/products/1
Content-Type: application/json
{ "sku": "SKU-001", "name": "Vitamina C Forte", "stock": 90 }
### borrar
DELETE {{base}}/api/products/5
Tests de integración: levantan el API de verdad en memoria y le pegan como un cliente. Cubren POST / PUT / DELETE de punta a punta.
// backend/Prueba.Api.Tests/ProductsApiTests.cs
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProductsApiTests(WebApplicationFactory<Program> f) => _client = f.CreateClient();
[Fact]
public async Task Get_lista_devuelve_200_y_paginado()
{
var res = await _client.GetAsync("/api/products?pageIndex=0&pageSize=5");
res.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await res.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
body!.Items.Count.Should().BeLessThanOrEqualTo(5);
}
[Fact]
public async Task Post_valido_crea_y_devuelve_201()
{
var input = new { sku = $"T-{Guid.NewGuid():N}".Substring(0, 12), name = "Test", stock = 5 };
var res = await _client.PostAsJsonAsync("/api/products", input);
res.StatusCode.Should().Be(HttpStatusCode.Created);
res.Headers.Location.Should().NotBeNull();
var created = await res.Content.ReadFromJsonAsync<ProductDto>();
await _client.DeleteAsync($"/api/products/{created!.ProductId}"); // limpieza
}
[Fact]
public async Task Post_invalido_devuelve_400()
{
var res = await _client.PostAsJsonAsync("/api/products",
new { sku = "x", name = "", stock = -1 });
res.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Put_inexistente_devuelve_404()
{
var res = await _client.PutAsJsonAsync("/api/products/999999",
new { sku = "SKU-ZZZ", name = "X", stock = 1 });
res.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Delete_inexistente_devuelve_404()
=> (await _client.DeleteAsync("/api/products/999999"))
.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
dotnet test # todos verdes antes de la entrevista
Estos tests tocan tu PruebaTienda real. Para aislarlos: (a) una BD PruebaTienda_Test aparte, apuntando el connection string desde WebApplicationFactory con WithWebHostBuilder + ConfigureAppConfiguration; (b) envolver cada test en una transacción con rollback; (c) el paquete Respawn para limpiar entre tests. Para la prueba, (a) es lo más fácil de explicar.
La política CORS "dev" ya está en Program.cs. Del lado de Angular, un proxy manda /api al backend para no usar URLs absolutas:
// frontend/proxy.conf.json
{
"/api": {
"target": "https://localhost:7xxx",
"secure": false,
"changeOrigin": true
}
}
Se arranca con ng serve --proxy-config proxy.conf.json (el script del paso 8 ya lo hace).
cd .. # de vuelta a prueba-newco/
ng new frontend --style=scss --ssr=false # acepta "routing? Yes"
cd frontend
ng add @angular/material # tema a elegir, Typography: Yes, Animations: Yes
ng add @angular/material instala la última versión que corresponde a tu Angular y configura el tema.
// frontend/src/app/app.config.ts
import { provideHttpClient } from '@angular/common/http';
// ...
providers: [provideHttpClient(), /* ...lo demás */]
// frontend/src/app/product.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
export interface Product { productId: number; sku: string; name: string; stock: number; }
export interface ProductInput { sku: string; name: string; stock: number; }
export interface Paged<T> { items: T[]; total: number; pageIndex: number; pageSize: number; }
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private base = '/api/products';
list(pageIndex: number, pageSize: number): Observable<Paged<Product>> {
return this.http.get<Paged<Product>>(`${this.base}?pageIndex=${pageIndex}&pageSize=${pageSize}`);
}
get(id: number) { return this.http.get<Product>(`${this.base}/${id}`); }
create(dto: ProductInput) { return this.http.post<Product>(this.base, dto); }
update(id: number, dto: ProductInput) { return this.http.put<void>(`${this.base}/${id}`, dto); }
remove(id: number) { return this.http.delete<void>(`${this.base}/${id}`); }
}
// frontend/src/app/products-page.component.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { ProductService, Product } from './product.service';
@Component({
selector: 'app-products-page',
standalone: true,
imports: [ReactiveFormsModule, MatTableModule, MatPaginatorModule, MatFormFieldModule,
MatInputModule, MatButtonModule, MatSnackBarModule],
templateUrl: './products-page.component.html',
})
export class ProductsPageComponent {
private api = inject(ProductService);
private fb = inject(FormBuilder);
private snack = inject(MatSnackBar);
cols = ['productId', 'sku', 'name', 'stock', 'acciones'];
rows: Product[] = [];
total = 0; pageIndex = 0; pageSize = 5;
editingId: number | null = null;
form = this.fb.nonNullable.group({
sku: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(32)]],
name: ['', [Validators.required, Validators.minLength(2)]],
stock: [0, [Validators.required, Validators.min(0)]],
});
ngOnInit() { this.load(); }
load() {
this.api.list(this.pageIndex, this.pageSize)
.subscribe(r => { this.rows = r.items; this.total = r.total; });
}
onPage(e: { pageIndex: number; pageSize: number }) {
this.pageIndex = e.pageIndex; this.pageSize = e.pageSize; this.load();
}
submit() {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
const dto = this.form.getRawValue();
const ok = (msg: string) => {
this.snack.open(msg, 'OK', { duration: 2500 });
this.form.reset({ stock: 0 }); this.editingId = null; this.load();
};
const fail = (e: any) => this.snack.open('Error ' + (e.status ?? ''), 'Cerrar', { duration: 3500 });
if (this.editingId == null)
this.api.create(dto).subscribe({ next: () => ok('Creado'), error: fail });
else
this.api.update(this.editingId, dto).subscribe({ next: () => ok('Actualizado'), error: fail });
}
edit(p: Product) {
this.editingId = p.productId;
this.form.setValue({ sku: p.sku, name: p.name, stock: p.stock });
}
remove(p: Product) {
this.api.remove(p.productId).subscribe({
next: () => { this.snack.open('Eliminado', 'OK', { duration: 2000 }); this.load(); },
error: () => this.snack.open('No se pudo eliminar', 'Cerrar', { duration: 3000 }),
});
}
}
<!-- frontend/src/app/products-page.component.html -->
<form [formGroup]="form" (ngSubmit)="submit()">
<mat-form-field>
<mat-label>SKU</mat-label>
<input matInput formControlName="sku">
<mat-error>SKU de 3 a 32 caracteres</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Nombre</mat-label>
<input matInput formControlName="name">
<mat-error>Requerido</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Stock</mat-label>
<input matInput type="number" formControlName="stock">
<mat-error>No negativo</mat-error>
</mat-form-field>
<button mat-flat-button color="primary" type="submit">
{{ editingId == null ? 'Crear' : 'Guardar' }}
</button>
</form>
<table mat-table [dataSource]="rows">
<ng-container matColumnDef="productId">
<th mat-header-cell *matHeaderCellDef>Id</th>
<td mat-cell *matCellDef="let p">{{ p.productId }}</td>
</ng-container>
<ng-container matColumnDef="sku">
<th mat-header-cell *matHeaderCellDef>SKU</th>
<td mat-cell *matCellDef="let p">{{ p.sku }}</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Nombre</th>
<td mat-cell *matCellDef="let p">{{ p.name }}</td>
</ng-container>
<ng-container matColumnDef="stock">
<th mat-header-cell *matHeaderCellDef>Stock</th>
<td mat-cell *matCellDef="let p">{{ p.stock }}</td>
</ng-container>
<ng-container matColumnDef="acciones">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let p">
<button mat-button (click)="edit(p)">Editar</button>
<button mat-button color="warn" (click)="remove(p)">Eliminar</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="cols"></tr>
<tr mat-row *matRowDef="let row; columns: cols;"></tr>
</table>
<mat-paginator [length]="total" [pageSize]="pageSize"
[pageSizeOptions]="[5, 10, 25]" (page)="onPage($event)">
</mat-paginator>
Paginación del lado del servidor: el mat-paginator emite (page) → recargas pidiendo esa página al API. [length]="total" viene del COUNT(*) del back.
// app.routes.ts
export const routes = [
{ path: '', loadComponent: () =>
import('./products-page.component').then(m => m.ProductsPageComponent) },
];
// product.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ProductService } from './product.service';
describe('ProductService', () => {
let svc: ProductService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
svc = TestBed.inject(ProductService);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('list() pide la pagina correcta', () => {
svc.list(2, 10).subscribe();
const req = http.expectOne('/api/products?pageIndex=2&pageSize=10');
expect(req.request.method).toBe('GET');
req.flush({ items: [], total: 0, pageIndex: 2, pageSize: 10 });
});
it('create() hace POST con el body', () => {
const dto = { sku: 'SKU-9', name: 'Zinc', stock: 3 };
svc.create(dto).subscribe();
const req = http.expectOne('/api/products');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(dto);
req.flush({ productId: 9, ...dto });
});
it('update() hace PUT a /:id', () => {
svc.update(9, { sku: 'SKU-9', name: 'Zinc', stock: 5 }).subscribe();
const req = http.expectOne('/api/products/9');
expect(req.request.method).toBe('PUT');
req.flush(null);
});
it('remove() hace DELETE a /:id', () => {
svc.remove(9).subscribe();
const req = http.expectOne('/api/products/9');
expect(req.request.method).toBe('DELETE');
req.flush(null);
});
});
ng test # Karma/Jasmine. Verde antes de la entrevista.
package.json en la raíz de prueba-newco/:
{
"name": "prueba-newco",
"scripts": {
"be": "dotnet watch run --project backend/Prueba.Api",
"fe": "ng serve --project frontend --proxy-config frontend/proxy.conf.json --open",
"dev": "concurrently -n API,WEB -c blue,green \"npm:be\" \"npm:fe\"",
"test:be": "dotnet test backend/Prueba.sln",
"test:fe": "ng test --project frontend --watch=false",
"test": "npm run test:be && npm run test:fe",
"db:reset": "powershell -File db/reset.ps1"
},
"devDependencies": { "concurrently": "^9.0.0" }
}
npm i # instala concurrently
npm run dev # levanta API + Angular a la vez
npm test # corre TODOS los tests
db/reset.ps1:
sqlcmd -S "localhost\SQLEXPRESS" -E -C -i db/schema.sql
sqlcmd -S "localhost\SQLEXPRESS" -E -C -i db/seed.sql
Write-Host "BD PruebaTienda reseteada" -ForegroundColor Green
db/schema.sql = los CREATE TABLE de la pestaña SQL · paso a paso; db/seed.sql = los INSERT.
// .vscode/tasks.json
{
"version": "2.0.0",
"tasks": [
{ "label": "API", "type": "shell", "command": "npm run be",
"isBackground": true, "problemMatcher": [] },
{ "label": "WEB", "type": "shell", "command": "npm run fe",
"isBackground": true, "problemMatcher": [] },
{ "label": "Dev (API+WEB)", "dependsOn": ["API", "WEB"], "problemMatcher": [],
"group": { "kind": "build", "isDefault": true } },
{ "label": "Test todo", "type": "shell", "command": "npm test",
"group": "test", "problemMatcher": [] }
]
}
// .vscode/launch.json (ajusta net9.0 / net8.0 a tu SDK)
{
"version": "0.2.0",
"configurations": [
{ "name": "API (debug)", "type": "coreclr", "request": "launch",
"program": "${workspaceFolder}/backend/Prueba.Api/bin/Debug/net9.0/Prueba.Api.dll",
"cwd": "${workspaceFolder}/backend/Prueba.Api",
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
} }
]
}
// .vscode/settings.json
{
"editor.formatOnSave": true,
"dotnet.defaultSolution": "backend/Prueba.sln",
"mssql.connections": [
{ "server": "localhost", "authenticationType": "Integrated",
"database": "PruebaTienda", "profileName": "local" }
]
}
// .vscode/extensions.json
{ "recommendations": [
"ms-dotnettools.csdevkit", "angular.ng-template", "humao.rest-client",
"ms-mssql.mssql", "editorconfig.editorconfig"
] }
| Tecla | Hace |
|---|---|
| F5 | Iniciar / depurar el API con breakpoints |
| Ctrl+Shift+B | Correr la task por defecto (API+WEB) |
| Ctrl+ñ / Ctrl+` | Abrir / cerrar la terminal integrada |
| Ctrl+Shift+P → "Test: Run All Tests" | Correr todos los tests de .NET |
| F12 / Alt+← | Ir a definición / volver |
| Ctrl+. | Quick fix (agregar using, generar constructor…) |
npm run dev levanta API (/swagger) y Angular (localhost:4200) sin errores.snackbar.npm test → backend y frontend todo verde.db/reset.ps1 recrea la BD si la ensucias durante la prueba.OFFSET/FETCH y el COUNT(*) en la misma ida con QueryMultiple, para no hacer dos round-trips."Location en POST, 204 en PUT/DELETE, 404 si ExecuteAsync afecta 0 filas, 400 automático por los DataAnnotations."localhost:4200 en dev; en Angular un proxy.conf.json para pegarle a /api sin URLs absolutas."mat-paginator emite (page) y recargo esa página. Feedback con MatSnackBar. Formulario reactivo tipado con los mismos límites que valida el back."WebApplicationFactory cubriendo los 5 verbos y los casos 400/404. Frontend: HttpTestingController verificando método, URL y body de cada operación."npm test, verde. ~5 min.Formato confirmado (correo del 31 ago): la prueba es un fichero suelto con 3 piezas (SQL + método + endpoint), no compila, sin proyecto ni BD suya. Esta pestaña —proyecto completo, CRUD, pantalla Angular, tests, plantillas dotnet new— es práctica de fondo, ya no el guion. El guion real está en La prueba.
Cada paso: Escribes (el snippet o comando) · Aparece (lo que sale en pantalla) · Rellenas · Clave (la línea que explica el porqué). Sigue en orden.
El arranque, en un gesto por lado. Back: dotnet new dapper-slice -n Tienda --entity Product. Front: ng new + ng add @angular/material y los 5 snippets Angular (!proxy !svc !page !pagehtml !route). Los dos dejan solo la estructura vacía.
Antes: instala las 2 plantillas — dotnet new install .\plantilla-dapper-slice y dotnet new install .\plantilla-dapper-entity — y los snippets: VS Code ▸ Ctrl+Shift+P ▸ "Snippets: Configure Snippets" ▸ New Global Snippets file ▸ pega dapper-slice.code-snippets (10: !repo !join !crud !fact !httpblk + 5 de Angular). Necesitas "editor.tabCompletion": "onlySnippets" en settings.json o el Tab no expande.
Ten abierto: SSMS, VS Code, navegador. 3 terminales en VS Code (Ctrl+ñ y el +): API · Angular · tests.
Cada paso lleva una etiqueta:
🧠 de memoria — lo que evalúan: la query, el repo, los verbos, el componente. Tecléalo tú, explicando. El snippet (!repo, !join, !crud, !svc, !page) es solo para verificar la forma mientras practicas. Cada paso 🧠 trae un recuadro "cómo memorizarlo".
✅ úsalo — comando (dotnet new, ng) o texto repetitivo que nadie escribe a mano (!fact, !httpblk, !proxy). Dispáralo sin culpa.
!join (Paso 19) es el más importante de todos: el patrón que decide. Apréndelo hasta escribirlo en blanco. Los 3 detalles que lo hacen correcto: rango semiabierto (>= @from AND < @toExclusive, nunca BETWEEN) · SARGable (filtro contra la columna cruda, sin CAST) · ORDER BY determinista que termina en columna única.
Mecánica del tab-stop (para practicar). Al expandir, el cursor cae en el 1er hueco con un valor de ejemplo seleccionado — Tab lo acepta o escribes encima. Los huecos con el mismo número están espejados: cambias uno y se actualizan todos.
!repo tiene 2 huecos: la entidad (deriva <E>Dto, <E>Input, dbo.<E>, la PK <E>Id) y la lista de columnas (deriva los @valores y el SET con una transformación). El default es Product como ejemplo, igual que un snippet de bucle usa i.
Más de una tabla. dotnet new dapper-entity --entity Order --ns Tienda.Api agrega los stubs de otra entidad — OrderRepository, OrdersController (api/orders), OrderDto/OrderInput, todo vacío. Se corre N veces, una por tabla. El repo se auto-registra: Program.cs escanea los *Repository del ensamblado, no lo tocas. El plural lo deriva (Category → api/categories).
Product (catálogo, con UNIQUE en Sku + CHECK en Stock), Customer, Order (FK a Customer + fecha), OrderLine (FK a Order y Product + cantidad/precio). Dibuja el diagrama en papel 3 veces. Di mientras tecleas: "El invariante vive en la BD: UNIQUE, CHECK, FOREIGN KEY — no solo en C#."Escribes: SSMS ▸ Connect (localhost\SQLEXPRESS, Windows Auth, Trust server certificate) ▸ New Query ▸ pega y F5:
IF DB_ID('PruebaApi') IS NOT NULL
BEGIN
ALTER DATABASE PruebaApi SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE PruebaApi;
END
GO
CREATE DATABASE PruebaApi;
GO
USE PruebaApi;
GO
CREATE TABLE dbo.Product (
ProductId INT IDENTITY PRIMARY KEY,
Sku VARCHAR(32) NOT NULL UNIQUE,
Name NVARCHAR(160) NOT NULL,
Stock INT NOT NULL CONSTRAINT CK_Product_Stock CHECK (Stock >= 0)
);
CREATE TABLE dbo.Customer (
CustomerId INT IDENTITY PRIMARY KEY,
FullName NVARCHAR(120) NOT NULL,
Email NVARCHAR(160) NOT NULL
);
CREATE TABLE dbo.[Order] (
OrderId INT IDENTITY PRIMARY KEY,
CustomerId INT NOT NULL REFERENCES dbo.Customer(CustomerId),
OrderDate DATETIME2(0) NOT NULL,
Status VARCHAR(20) NOT NULL
);
CREATE TABLE dbo.OrderLine (
OrderLineId INT IDENTITY PRIMARY KEY,
OrderId INT NOT NULL REFERENCES dbo.[Order](OrderId),
ProductId INT NOT NULL REFERENCES dbo.Product(ProductId),
Quantity INT NOT NULL,
UnitPrice DECIMAL(10,2) NOT NULL
);
CREATE INDEX IX_Order_Date ON dbo.[Order](OrderDate DESC, OrderId DESC) INCLUDE (CustomerId, Status);
CREATE INDEX IX_OrderLine_Order ON dbo.OrderLine(OrderId);
CREATE INDEX IX_OrderLine_Product ON dbo.OrderLine(ProductId);
GO
INSERT INTO dbo.Product (Sku, Name, Stock) VALUES
('SKU-001','Vitamina C',100),('SKU-002','Probiótico',50),('SKU-003','Omega 3',12),
('SKU-004','Colágeno',30),('SKU-005','Magnesio',8);
INSERT INTO dbo.Customer (FullName, Email) VALUES
('Ana López','ana@mail.com'),('Beto Ruiz','beto@mail.com'),('Carmen Díaz','carmen@mail.com');
INSERT INTO dbo.[Order] (CustomerId, OrderDate, Status) VALUES
(1,'2026-01-05 10:00:00','Paid'),(2,'2026-01-20 16:30:00','Paid'),
(1,'2026-02-03 09:15:00','Pending'),(3,'2026-01-28 12:00:00','Paid');
INSERT INTO dbo.OrderLine (OrderId, ProductId, Quantity, UnitPrice) VALUES
(1,1,2,15.00),(1,2,1,25.00),(2,3,1,30.00),(3,1,5,15.00),(4,2,3,25.00),(4,1,1,15.00);
GO
SELECT COUNT(*) AS Productos FROM dbo.Product; -- 5
SELECT COUNT(*) AS Renglones FROM dbo.OrderLine; -- 6
Ves: Productos = 5, Renglones = 6. El pedido 3 es de febrero — lo usa el filtro del Paso 20.
Clave: "Empiezo por los datos. UNIQUE en SKU, CHECK en stock, las FOREIGN KEY — el invariante vive en la base, no solo en C#."
Escribes: VS Code ▸ File ▸ Open Folder ▸ carpeta vacía (C:\dev\tienda) ▸ terminal (Ctrl+ñ) → Terminal 1.
Qué hace: arma la solución vacía (sln + Api + Tests), instala Dapper, cablea DI/CORS/Swagger y deja los archivos vacíos. Nadie escribe un .sln a mano — esto es normal. Di: "Uso un template de equipo para el arranque; la lógica la escribo yo."
Escribes (Terminal 1) — le pasas el nombre del agregado que te pidan (aquí Product; sería Order, Invoice…):
dotnet new dapper-slice -n Tienda --entity Product
dotnet build
Aparece — el esqueleto entero, con las clases ya nombradas por --entity, compilando:
Tienda.sln
Tienda.Api/
Program.cs (Swagger + conexión SQL + CORS + repos por convención)
appsettings.Development.json ("Sql": "Server=localhost\SQLEXPRESS;Database=PruebaApi;...")
Models.cs (records vacíos)
ProductRepository.cs (clase vacía)
OrderReportRepository.cs (clase vacía · el repo del reporte va aparte)
Controllers/ProductsController.cs ([ApiController] + ruta, sin acciones)
Controllers/ReportsController.cs ([ApiController] + ruta, sin acciones)
Tienda.Api.http (una request de ejemplo)
Tienda.Api.Tests/
ProductsApiTests.cs (clase con WebApplicationFactory<Program> + _client)
Ves: Compilación correcta. 0 Errores (con warnings de "param sin usar" — normal en stubs). Con --entity Order saldría OrderRepository, OrdersController, api/orders, OrdersApiTests — el plural lo deriva solo.
¿Más tablas? Desde Tienda.Api/: dotnet new dapper-entity --entity Order --ns Tienda.Api (una vez por tabla). Agrega <Entidad>Repository + <Entidad>sController + <Entidad>Dto/Input, todo vacío. No tocas Program.cs: los repos entran por convención.
Clave: "Es un template de equipo: le paso el agregado y arma el esqueleto —repo, controller, tests, ruta— más el wiring (DI por convención, CORS, Swagger). Los cuerpos vacíos; la lógica la escribo yo."
record XDto(...) = lo que sale; class XInput con [Required]/[StringLength]/[Range] = lo que entra; PagedResult<T> no se toca. Di: "Con DataAnnotations en el input, si el body no cumple ASP.NET devuelve 400 solo — sin un if mío."Al abrir Models.cs ves:
namespace Tienda.Api;
// modelos de entrada y salida
public record ProductDto();
public class ProductInput { }
public record PagedResult<T>(IReadOnlyList<T> Items, int Total, int PageIndex, int PageSize);
Qué poner — completas los dos vacíos (el PagedResult queda igual):
public record ProductDto(int ProductId, string Sku, string Name, int Stock);
public class ProductInput
{
[Required, StringLength(32, MinimumLength = 3)] public string Sku { get; set; } = "";
[Required, StringLength(160, MinimumLength = 2)] public string Name { get; set; } = "";
[Range(0, int.MaxValue, ErrorMessage = "El stock no puede ser negativo.")]
public int Stock { get; set; }
}
// PagedResult<T> se queda igual. Arriba: using System.ComponentModel.DataAnnotations;
Clave: "El DTO es lo que sale. ProductInput lo que entra — con [Required]/[Range]: si el body no cumple, ASP.NET devuelve 400 sin que yo escriba un if."
!repo es para verificar la forma mientras practicas; en la prueba lo tecleas tú. Memorízalo por método, cada uno es una frase: GetPaged = "página + COUNT en una ida con QueryMultiple". GetById = "QuerySingleOrDefault, null si no hay". Create = "INSERT + SELECT SCOPE_IDENTITY()". Update/Delete = "ExecuteAsync devuelve filas afectadas; 0 = 404". Drill: escríbelo en blanco, tápalo, repite hasta 2 veces sin fallo. Di: "SQL a mano, siempre parametrizado."Al abrir ProductRepository.cs ves:
using System.Data;
using Dapper;
namespace Tienda.Api;
public class ProductRepository(IDbConnection db)
{
// lógica del repositorio
}
Escribes (dentro de public class ProductRepository(IDbConnection db) { }): !repo Tab
Aparece — los 5 métodos de una vez. Solo 2 tab-stops: 1 la entidad (Product, seleccionada) → deriva ProductDto, ProductInput, dbo.Product y la PK ProductId. Tab. 2 las columnas editables (Sku, Name, Stock) → de ahí saca solo los @valores y el SET:
public async Task<PagedResult<ProductDto>> GetPagedAsync(int pageIndex, int pageSize)
{
const string sql = @"
SELECT ProductId, Sku, Name, Stock
FROM dbo.Product
ORDER BY ProductId DESC
OFFSET (@pageIndex * @pageSize) ROWS FETCH NEXT @pageSize ROWS ONLY;
SELECT COUNT(*) FROM dbo.Product;";
using var grid = await db.QueryMultipleAsync(sql, new { pageIndex, pageSize });
var items = (await grid.ReadAsync<ProductDto>()).AsList();
var total = await grid.ReadSingleAsync<int>();
return new PagedResult<ProductDto>(items, total, pageIndex, pageSize);
}
public Task<ProductDto?> GetByIdAsync(int id) => db.QuerySingleOrDefaultAsync<ProductDto>(
"SELECT ProductId, Sku, Name, Stock FROM dbo.Product WHERE ProductId = @id", new { id });
public Task<int> CreateAsync(ProductInput input) => db.ExecuteScalarAsync<int>(@"
INSERT INTO dbo.Product (Sku, Name, Stock) VALUES (@Sku, @Name, @Stock);
SELECT CAST(SCOPE_IDENTITY() AS int);", input);
public Task<int> UpdateAsync(int id, ProductInput input)
{
var prms = new DynamicParameters(input);
prms.Add("id", id);
return db.ExecuteAsync(
"UPDATE dbo.Product SET Sku = @Sku, Name = @Name, Stock = @Stock WHERE ProductId = @id", prms);
}
public Task<int> DeleteAsync(int id) => db.ExecuteAsync(
"DELETE FROM dbo.Product WHERE ProductId = @id", new { id });
Rellenas: para Product, hueco 1 = Product (Tab), hueco 2 = Sku, Name, Stock (Tab). Fin. Para Customer: hueco 1 = Customer, hueco 2 = FullName, Email — y sale CustomerDto, dbo.Customer, CustomerId, @FullName, @Email, todo solo. Arriba del archivo, los using ya vienen en el stub (System.Data, Dapper).
Nota: el snippet asume PK = <Entidad>Id y tipos <Entidad>Dto / <Entidad>Input (la convención del proyecto). Si el esquema no la sigue, ajustas esos nombres a mano.
Clave: "Un snippet con la forma de un repositorio Dapper: OFFSET/FETCH + COUNT en una ida con QueryMultiple, SCOPE_IDENTITY en el insert, DynamicParameters en el update para no repetir la lista de campos, y Update/Delete devuelven filas afectadas: 0 = 404. Le paso la entidad y las columnas; el SQL es mío."
Location (CreatedAtAction)|400 · PUT→204|404 · DELETE→204|404. El 400 no lo escribes: lo da el binder por los DataAnnotations. Drill: recita la tabla en voz alta 5 veces. Di: "201 con Location en POST, 204 sin body en PUT/DELETE, 404 cuando afecta 0 filas."Al abrir Controllers/ProductsController.cs ves (el [ApiController], la ruta y el (ProductRepository repo) ya vienen):
[ApiController]
[Route("api/products")]
public class ProductsController(ProductRepository repo) : ControllerBase
{
// endpoints
}
Qué poner — dentro de la clase: !crud Tab
Aparece:
[HttpGet]
public async Task<ActionResult<PagedResult<ProductDto>>> GetPaged(int pageIndex = 0, int pageSize = 10)
=> Ok(await repo.GetPagedAsync(Math.Max(0, pageIndex), Math.Clamp(pageSize, 1, 100)));
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id)
=> await repo.GetByIdAsync(id) is { } x ? Ok(x) : NotFound();
[HttpPost]
public async Task<ActionResult<ProductDto>> Create(ProductInput input)
{
var id = await repo.CreateAsync(input);
return CreatedAtAction(nameof(GetById), new { id }, await repo.GetByIdAsync(id));
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, ProductInput input)
=> await repo.UpdateAsync(id, input) == 0 ? NotFound() : NoContent();
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
=> await repo.DeleteAsync(id) == 0 ? NotFound() : NoContent();
Rellenas: tab-stop 1 = ProductDto (5 sitios), tab-stop 2 = ProductInput (2 sitios) — para otra entidad cambias esos dos y ya. Si un tipo sale en rojo: Ctrl+. ▸ "using …".
Clave: "201 + Location en POST, 204 sin body en PUT/DELETE, 404 cuando ExecuteAsync afecta 0 filas, y el 400 lo da solo el binder por los DataAnnotations."
Escribes (Terminal 1): dotnet run --project backend/Tienda.Api
Ves: Now listening on: https://localhost:7xxx — apunta el puerto.
Clave: "Antes de tocar Angular, la API tiene que estar verde por sí sola."
Escribes: navegador → https://localhost:7xxx/swagger (aviso de certificado → Advanced ▸ Proceed). GET /api/products ▸ Try it out ▸ Execute.
Ves: 200, 5 productos en items, total: 5.
Qué hace !httpblk: pega un bloque ### con verbo + URL + body. Es formato de REST Client, no lógica — nadie lo evalúa. Tú pones los 9 casos (bueno y malo de cada verbo).
Escribes (borra el contenido del Tienda.Api.http, pon @base = https://localhost:7xxx con tu puerto, luego !httpblk Tab):
Aparece por cada Tab:
### descripción -> 200
GET {{base}}/api/products
Content-Type: application/json
{ "sku": "SKU-1", "name": "X", "stock": 1 }
Rellenas (9 bloques): la descripción, el código esperado, el verbo, el path, y el body (bórralo en GET/DELETE). Los 9 casos: lista→200 · id 1→200 · id 9999→404 · POST válido→201 · POST {sku:"x",name:"",stock:-2}→400 · PUT id 1→204 · PUT 9999→404 · DELETE 5→204 · DELETE 9999→404.
Ves: clic en Send Request de cada uno → 200, 200, 404, 201, 400, 204, 404, 204, 404.
Clave: "Pruebo el caso bueno y el malo de cada verbo. El POST inválido tiene que dar 400, no 500."
Qué hace !fact: deja el [Fact] + la llamada + el assert de status. El esqueleto es repetitivo, dispáralo; tú piensas qué caso cubre y los asserts extra. Di: "Integración con WebApplicationFactory: levanta la API real en memoria."
Escribes (en ProductsApiTests.cs, agrega los using de System.Net / System.Net.Http.Json / FluentAssertions, y dentro de la clase): !fact Tab
Aparece:
[Fact]
public async Task Get_lista_200()
{
var res = await _client.GetAsync("/api/products?pageIndex=0&pageSize=3");
res.StatusCode.Should().Be(HttpStatusCode.OK);
}
Rellenas (6 veces): el nombre, el método (_client.GetAsync / PostAsJsonAsync("/api/products", new {...}) / PutAsJsonAsync / DeleteAsync), la URL, el código (OK/NotFound/BadRequest/Created), y algún assert extra (p. ej. b.Total.Should().BeGreaterThan(0)).
Escribes (Terminal 2): dotnet test
Ves: Passed! - Failed: 0, Passed: 6
Clave: "Integración con WebApplicationFactory — levanta la API real en memoria. Cubren 200, 404, 400 y el 201 de punta a punta."
Escribes (Terminal 2, en C:\dev\tienda): ng new frontend --style=scss --ssr=false (routing? → Yes).
Clave: "Standalone, con Material. La tabla pagina del lado del servidor: el paginador pide la página al API, no traigo todo y filtro en memoria."
Escribes: cd frontend · ng add @angular/material (tema cualquiera · typography: Yes · animations: Yes).
Ves: src/styles.scss con el bloque de tema de Material.
Clave: "ng add instala Material en su última versión y engancha el tema. De ahí saco la tabla, el paginador y los inputs con error visual."
Qué hace !proxy: una línea de JSON que manda /api/* al backend. Config pura. Di: "El proxy evita CORS y URLs absolutas: el servicio usa rutas relativas."
Escribes: archivo nuevo frontend/proxy.conf.json ▸ dentro: !proxy Tab
Aparece:
{ "/api": { "target": "https://localhost:7xxx", "secure": false, "changeOrigin": true } }
Rellenas: el 7xxx → el puerto real de la API (el del Paso 7). Nada más.
Clave: "El proxy manda /api/* al backend; así el servicio usa rutas relativas y no hay líos de CORS ni URLs absolutas."
Aparece (generado, Angular 20):
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes)
]
Rellenas: import { provideHttpClient } from '@angular/common/http'; arriba, y provideHttpClient() al final del array.
interface (el contrato, mismos nombres que el JSON de .NET en camelCase) + un método por operación del back (list/create/update/remove), inject(HttpClient), base = '/api/products'. En la prueba lo tecleas; !svc es para practicar. Di: "Las interfaces son el contrato; un método por endpoint."Escribes (Terminal 2): ng g s product --type=service.
Al abrir src/app/product.service.ts ves (lo genera casi vacío):
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class ProductService {
}
Qué poner — borras el cuerpo y: !svc Tab. Debe quedar así:
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
export interface Product { productId: number; sku: string; name: string; stock: number; }
export interface ProductInput { sku: string; name: string; stock: number; }
export interface Paged<T> { items: T[]; total: number; pageIndex: number; pageSize: number; }
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private base = '/api/products';
list(pageIndex: number, pageSize: number): Observable<Paged<Product>> {
return this.http.get<Paged<Product>>(
this.base + '?pageIndex=' + pageIndex + '&pageSize=' + pageSize);
}
create(dto: ProductInput) { return this.http.post<Product>(this.base, dto); }
update(id: number, dto: ProductInput) { return this.http.put<void>(this.base + '/' + id, dto); }
remove(id: number) { return this.http.delete<void>(this.base + '/' + id); }
}
Rellenas: 3 tab-stops — nombre de la clase (ProductService), ruta base (/api/products) y el tipo del genérico (Product). Las interface con los campos (sku, name, stock) las escribes según la entidad real; el resto es forma fija. Para Product, Tab y sigues.
Clave: "Un método por operación del back. Las interface son el contrato — mismos nombres de campo que el JSON de .NET, en camelCase."
mat-table con displayedColumns; (2) mat-paginator que emite (page) → recargas esa página del server (no traes todo); (3) form reactivo con los mismos límites que el back; (4) MatSnackBar para feedback. Es la pieza más larga — si el tiempo aprieta, tabla + paginador primero, form después. !page/!pagehtml para practicar. Di: "Paginación server-side; misma validación que el back."Escribes (Terminal 2): ng g c products-page --type=component.
Al abrir la carpeta src/app/products-page/ ves 4 archivos; el .ts viene así y el .html con un <p>products-page works!</p>:
@Component({
selector: 'app-products-page',
imports: [],
templateUrl: './products-page.component.html',
styleUrl: './products-page.component.scss'
})
export class ProductsPageComponent { }
Qué poner — borras el cuerpo de cada archivo y disparas el snippet:
a) products-page/products-page.component.ts → !page Tab · debe quedar:
Aparece (el componente entero):
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { ProductService, Product } from '../product.service';
@Component({
selector: 'app-products-page',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, MatTableModule, MatPaginatorModule,
MatFormFieldModule, MatInputModule, MatButtonModule, MatSnackBarModule],
templateUrl: './products-page.component.html',
styleUrl: './products-page.component.scss',
})
export class ProductsPageComponent implements OnInit {
private api = inject(ProductService);
private fb = inject(FormBuilder);
private snack = inject(MatSnackBar);
cols = ['productId', 'sku', 'name', 'stock', 'acciones'];
rows: Product[] = [];
total = 0; pageIndex = 0; pageSize = 5;
editingId: number | null = null;
form = this.fb.nonNullable.group({
sku: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(32)]],
name: ['', [Validators.required, Validators.minLength(2)]],
stock: [0, [Validators.required, Validators.min(0)]],
});
ngOnInit() { this.load(); }
load() {
this.api.list(this.pageIndex, this.pageSize)
.subscribe(r => { this.rows = r.items; this.total = r.total; });
}
onPage(e: PageEvent) { this.pageIndex = e.pageIndex; this.pageSize = e.pageSize; this.load(); }
submit() {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
const dto = this.form.getRawValue();
const creating = this.editingId === null;
const ok = () => {
this.snack.open(creating ? 'Creado' : 'Actualizado', 'OK', { duration: 2500 });
this.editingId = null; this.form.reset({ sku: '', name: '', stock: 0 }); this.load();
};
const bad = (e: any) => this.snack.open('Error ' + (e && e.status || ''), 'X', { duration: 3500 });
(creating ? this.api.create(dto) : this.api.update(this.editingId!, dto))
.subscribe({ next: ok, error: bad });
}
edit(p: Product) {
this.editingId = p.productId;
this.form.setValue({ sku: p.sku, name: p.name, stock: p.stock });
}
remove(p: Product) {
if (!confirm('Eliminar ' + p.name + '?')) return;
this.api.remove(p.productId).subscribe({
next: () => { this.snack.open('Eliminado', 'OK', { duration: 2000 }); this.load(); },
error: () => this.snack.open('No se pudo eliminar', 'X', { duration: 3000 }),
});
}
}
b) products-page/products-page.component.html → !pagehtml Tab
Aparece (la plantilla entera):
<h1>Productos</h1>
<form [formGroup]="form" (ngSubmit)="submit()" style="display:flex;gap:12px;flex-wrap:wrap;align-items:baseline;margin-bottom:16px">
<mat-form-field><mat-label>SKU</mat-label><input matInput formControlName="sku"><mat-error>3 a 32</mat-error></mat-form-field>
<mat-form-field><mat-label>Nombre</mat-label><input matInput formControlName="name"><mat-error>Requerido</mat-error></mat-form-field>
<mat-form-field><mat-label>Stock</mat-label><input matInput type="number" formControlName="stock"><mat-error>No negativo</mat-error></mat-form-field>
<button mat-flat-button color="primary" type="submit">{{ editingId === null ? 'Crear' : 'Guardar' }}</button>
</form>
<table mat-table [dataSource]="rows">
<ng-container matColumnDef="productId"><th mat-header-cell *matHeaderCellDef>Id</th><td mat-cell *matCellDef="let p">{{ p.productId }}</td></ng-container>
<ng-container matColumnDef="sku"><th mat-header-cell *matHeaderCellDef>SKU</th><td mat-cell *matCellDef="let p">{{ p.sku }}</td></ng-container>
<ng-container matColumnDef="name"><th mat-header-cell *matHeaderCellDef>Nombre</th><td mat-cell *matCellDef="let p">{{ p.name }}</td></ng-container>
<ng-container matColumnDef="stock"><th mat-header-cell *matHeaderCellDef>Stock</th><td mat-cell *matCellDef="let p">{{ p.stock }}</td></ng-container>
<ng-container matColumnDef="acciones"><th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let p"><button mat-button (click)="edit(p)">Editar</button>
<button mat-button color="warn" (click)="remove(p)">Eliminar</button></td></ng-container>
<tr mat-header-row *matHeaderRowDef="cols"></tr>
<tr mat-row *matRowDef="let row; columns: cols;"></tr>
</table>
<mat-paginator [length]="total" [pageSize]="pageSize" [pageIndex]="pageIndex"
[pageSizeOptions]="[5, 10, 25]" (page)="onPage($event)"></mat-paginator>
c) src/app/app.routes.ts → borra el array y !route Tab
Aparece:
export const routes: Routes = [
{ path: '', loadComponent: () =>
import('./products-page/products-page.component').then(m => m.ProductsPageComponent) },
];
Rellenas: los tab-stops traen Product / products-page de ejemplo — para Product aceptas con Tab; para otra entidad cambias el nombre en el 1er stop y se replica. La grilla (cols, columnas del mat-table, campos del form) la ajustas a los campos reales. Deja src/app/app.html con solo <router-outlet />. Import de Material en rojo: Ctrl+. ▸ "Add all missing imports".
Clave: "Form reactivo tipado con los mismos límites que el back. Paginación server-side: el mat-paginator emite (page) y recargo esa página, no traigo todo. Feedback con MatSnackBar."
Escribes (Terminal 2, en frontend/): ng serve --proxy-config proxy.conf.json --open
Ves en http://localhost:4200, clic por clic:
SKU-777/Zinc/20 → aviso + fila nueva.-5 → campos en rojo, no envía.SKU-005 (sin pedidos) → confirma → desaparece. Si borras uno con pedidos, SQL lo rechaza por la FK — también correcto.Clave: "De la tabla que ves aquí hasta el CHECK de la base, cada capa conectada y probada por separado antes de unirla."
HttpTestingController → expectOne(url) → verificas method y body → flush(respuesta). Uno por método del servicio. Di: "Verifico URL, verbo y body sin tocar la red."Escribes (en product.service.spec.ts que creó ng g s, reemplaza el cuerpo con 4 it() de HttpTestingController), luego (Terminal 3): ng test --watch=false
Ves: Chrome headless arranca, SUCCESS en verde.
Clave: "HttpTestingController: por cada método verifico la URL, el verbo y el body, sin tocar la red."
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ProductService } from './product.service';
describe('ProductService', () => {
let svc: ProductService; let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
svc = TestBed.inject(ProductService);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('list() arma la URL', () => {
svc.list(2, 10).subscribe();
const r = http.expectOne('/api/products?pageIndex=2&pageSize=10');
expect(r.request.method).toBe('GET'); r.flush({ items: [], total: 0, pageIndex: 2, pageSize: 10 });
});
it('create() hace POST con body', () => {
const dto = { sku: 'SKU-9', name: 'Zinc', stock: 3 };
svc.create(dto).subscribe();
const r = http.expectOne('/api/products');
expect(r.request.method).toBe('POST'); expect(r.request.body).toEqual(dto); r.flush({ productId: 9, ...dto });
});
it('update() hace PUT a /:id', () => {
svc.update(9, { sku: 'SKU-9', name: 'Zinc', stock: 5 }).subscribe();
const r = http.expectOne('/api/products/9');
expect(r.request.method).toBe('PUT'); r.flush(null);
});
it('remove() hace DELETE a /:id', () => {
svc.remove(9).subscribe();
const r = http.expectOne('/api/products/9');
expect(r.request.method).toBe('DELETE'); r.flush(null);
});
});
Esta es la pregunta que decide. Escríbela de memoria, narrando. Nunca dispares !join aquí — es para verificar la forma cuando practicas.
Cómo memorizarla — en 6 trozos, cada uno con su frase:
SELECT — "traigo las columnas de las 4 tablas con alias claros; el total de línea lo calculo, Quantity * UnitPrice."FROM dbo.OrderLine AS ol — "arranco por OrderLine, que es la tabla base, el grano del reporte."INNER JOIN (Order, Customer, Product) — "le pego Order por OrderId, Customer por CustomerId, Product por ProductId; INNER porque solo quiero líneas completas."WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive — "rango semiabierto: nunca BETWEEN, que incluiría el límite y se rompe con horas."ORDER BY o.OrderDate DESC, o.OrderId DESC, ol.OrderLineId DESC — "orden determinista que termina en columna única — OFFSET/FETCH lo exige o la paginación baila."OFFSET (@pageIndex * @pageSize) ROWS FETCH NEXT @pageSize ROWS ONLY; — "paginación en el server." Y el COUNT(*) con el mismo WHERE en la misma ida (QueryMultiple).Filtro contra la columna cruda, sin CAST/CONVERT — SARGable, usa el índice. Drill: escríbela en blanco 3 veces seguidas sin mirar, diciendo las 6 frases en voz alta.
Al abrir OrderReportRepository.cs ves:
using System.Data;
using Dapper;
namespace Tienda.Api;
public record OrderLineRow();
public class OrderReportRepository(IDbConnection db)
{
// lógica del reporte
}
Qué poner: los 8 campos del record OrderLineRow(...), la firma GetOrderLinesAsync(DateTime fromDate, DateTime toExclusive, int pageIndex, int pageSize), const string sql = @", y —de memoria— el INNER JOIN.
El SQL debe quedar así (esto es lo que memorizas):
SELECT
o.OrderId, o.OrderDate,
c.FullName AS CustomerName,
p.Sku, p.Name AS ProductName,
ol.Quantity, ol.UnitPrice,
ol.Quantity * ol.UnitPrice AS LineTotal
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
INNER JOIN dbo.Customer AS c ON c.CustomerId = o.CustomerId
INNER JOIN dbo.Product AS p ON p.ProductId = ol.ProductId
WHERE o.OrderDate >= @fromDate
AND o.OrderDate < @toExclusive -- semiabierto, NUNCA BETWEEN
ORDER BY o.OrderDate DESC, o.OrderId DESC, ol.OrderLineId DESC -- determinista
OFFSET (@pageIndex * @pageSize) ROWS FETCH NEXT @pageSize ROWS ONLY;
Rellenas: nada del !join. Debajo, dentro del mismo @"...", agrega el 2º SELECT COUNT(*) FROM dbo.OrderLine ol JOIN dbo.[Order] o ON o.OrderId = ol.OrderId WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive; y cierra ";. Luego QueryMultipleAsync + ReadAsync + ReadSingleAsync<int> + return new PagedResult(...).
Clave: "Empiezo por OrderLine, la tabla base, y con INNER JOIN le pego Order, Customer, Product. Fecha semiabierta —>= @from AND < @toExclusive, nunca BETWEEN—. Filtro contra la columna cruda, sin CAST: SARGable. ORDER BY determinista que termina en columna única, porque OFFSET/FETCH lo exige. El COUNT en la misma ida con QueryMultiple."
using System.Data;
using Dapper;
namespace Tienda.Api;
public record OrderLineRow(
int OrderId, DateTime OrderDate, string CustomerName,
string Sku, string ProductName, int Quantity, decimal UnitPrice, decimal LineTotal);
public class OrderReportRepository(IDbConnection db)
{
public async Task<PagedResult<OrderLineRow>> GetOrderLinesAsync(
DateTime fromDate, DateTime toExclusive, int pageIndex, int pageSize)
{
const string sql = @"
SELECT
o.OrderId, o.OrderDate,
c.FullName AS CustomerName,
p.Sku, p.Name AS ProductName,
ol.Quantity, ol.UnitPrice,
ol.Quantity * ol.UnitPrice AS LineTotal
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
INNER JOIN dbo.Customer AS c ON c.CustomerId = o.CustomerId
INNER JOIN dbo.Product AS p ON p.ProductId = ol.ProductId
WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive
ORDER BY o.OrderDate DESC, o.OrderId DESC, ol.OrderLineId DESC
OFFSET (@pageIndex * @pageSize) ROWS FETCH NEXT @pageSize ROWS ONLY;
SELECT COUNT(*)
FROM dbo.OrderLine AS ol
INNER JOIN dbo.[Order] AS o ON o.OrderId = ol.OrderId
WHERE o.OrderDate >= @fromDate AND o.OrderDate < @toExclusive;";
using var grid = await db.QueryMultipleAsync(sql, new { fromDate, toExclusive, pageIndex, pageSize });
var items = (await grid.ReadAsync<OrderLineRow>()).AsList();
var total = await grid.ReadSingleAsync<int>();
return new PagedResult<OrderLineRow>(items, total, pageIndex, pageSize);
}
}
[HttpGet("order-lines")], params fromDate/toExclusive/pageIndex/pageSize, Ok(await repo...). Di: "El rango es semiabierto: el cliente manda el primer día del mes siguiente como toExclusive."Al abrir Controllers/ReportsController.cs ves (ruta y repo ya vienen):
[ApiController]
[Route("api/reports")]
public class ReportsController(OrderReportRepository repo) : ControllerBase
{
// endpoints
}
Qué poner — dentro de la clase debe quedar así:
[HttpGet("order-lines")]
public async Task<ActionResult<PagedResult<OrderLineRow>>> OrderLines(
DateTime fromDate, DateTime toExclusive, int pageIndex = 0, int pageSize = 20)
=> Ok(await repo.GetOrderLinesAsync(
fromDate, toExclusive, Math.Max(0, pageIndex), Math.Clamp(pageSize, 1, 100)));
En el .http, un bloque más (!httpblk):
### reporte enero 2026 -> 200
GET {{base}}/api/reports/order-lines?fromDate=2026-01-01&toExclusive=2026-02-01&pageIndex=0&pageSize=10
Ves: 200, total = 5 (renglones de enero; el pedido de febrero fuera), filas con customerName/productName/lineTotal.
Clave: "El rango es semiabierto: fromDate inclusive, toExclusive exclusive — el cliente manda el primer día del mes siguiente."
!fact + los asserts que fijan la regla. Di: "El test bloquea la regla: rango semiabierto, así que el pedido del 3 de febrero no entra."
Escribes (en ProductsApiTests.cs): !fact Tab
Rellenas: nombre Reporte_filtra_por_fecha, GetAsync("/api/reports/order-lines?fromDate=2026-01-01&toExclusive=2026-02-01&pageSize=3"), código OK, y tras el assert: body.Total.Should().Be(5); + body.Items.Should().OnlyContain(r => r.OrderDate >= new DateTime(2026,1,1) && r.OrderDate < new DateTime(2026,2,1));
Escribes: dotnet test
Ves: Passed: 7
Clave: "El test fija la regla: rango semiabierto, así que el pedido del 3 de febrero no entra."
| Costura | Dónde vive | Qué la hace funcionar |
|---|---|---|
| Base ↔ Back | appsettings.Development.json + Program.cs | connection string + AddScoped<IDbConnection>; Dapper la usa |
| Back ↔ Front (red) | frontend/proxy.conf.json | /api/* del dev-server se reenvía a https://localhost:7xxx |
| Back ↔ Front (permiso) | Program.cs, política "dev" | CORS deja pasar desde http://localhost:4200 |
Price)| # | Archivo | Cambio |
|---|---|---|
| 1 | SQL | ALTER TABLE dbo.Product ADD Price DECIMAL(10,2) NOT NULL CONSTRAINT DF_Price DEFAULT(0) CONSTRAINT CK_Price CHECK (Price > 0); + UPDATE viejas |
| 2–3 | Models.cs · ProductRepository.cs | , decimal Price en el DTO + [Range(0.01, 1000000)] en el input · Price en los SELECT/INSERT/UPDATE |
| 4–5 | controller · front · tests | controller: nada · price: number en el servicio · 'price' en cols + Validators.min(0.01) · test back (POST 0 → 400) + front |
Clave: "De abajo hacia arriba: el CHECK en la BD, el DTO para el 400, el SQL del repo, pruebo en Swagger, y recién ahí subo al front con el mismo límite."
dotnet test → 7 verdes · ng test --watch=false → verdes.INNER JOIN (Paso 19) sin mirar, explicando los 3 detalles.La prueba NO es construir un proyecto (correo del 31 ago): es un fichero suelto con SQL + método + endpoint, sin compilar. Esta pestaña queda como práctica de fondo para tener soltura conectando capas. El guion real: pestaña La prueba.
El mismo slice de Tienda de prueba, pero visto como orden de ejecución: base → API → front, tecleado a mano, razonando cada costura. Esta pestaña es el ritmo y la lógica; los contenidos exactos están en Tienda de prueba.
Esqueleto sí, lógica a mano. El arranque con dotnet new / ng new (y la plantilla y los snippets) deja solo la estructura vacía. Los cuerpos —la query, el repo, el componente— se escriben en el momento; ahí está el valor del ejercicio.
Un slice sólido se reconoce en que toma un requerimiento y lo lleva de la BD al front sin cabos sueltos. Se nota en estas señales:
| Señal | Qué demuestra |
|---|---|
| Empiezas por los datos y subes capa por capa | piensas en arquitectura, no en "pintar una pantalla" |
Pruebas la API sola (Swagger / .http) antes de tocar Angular | disciplina: aíslas el problema antes de que se acumule |
| Sabes de memoria las costuras: connection string, DI, CORS, proxy | no es tu primera vez conectando .NET + Angular |
| Escribes al menos un test sin que te lo pidan | testear es un hábito tuyo, no un extra |
| Usas los códigos HTTP correctos y dices por qué | entiendes REST, no solo "devolver JSON" |
| Cuando algo truena: lees el error → hipótesis → fix, en voz alta y sin pánico | así se trabaja de verdad; un bug no bloquea |
| Preguntas para acotar el requerimiento antes de teclear | traduces negocio → spec, que es literal lo que pide el JD |
| Explicas cada paso en voz alta | mantiene el hilo del razonamiento |
Fíjate que ninguna señal es "terminó rapidísimo" ni "la UI quedó bonita". Vale más un camino sólido sin terminar que uno atropellado.
Herramientas y config estándar. El boilerplate de un proyecto nuevo no se teclea a mano.
| Atajo | Qué es |
|---|---|
dotnet new sln / webapi --use-controllers / xunit | esqueleto de la solución — CLI oficial |
dotnet add package Dapper … | dependencias |
ng new frontend --style=scss --ssr=false · ng add @angular/material | proyecto Angular + Material (última versión) |
ng g s product · ng g c products-page | genera archivo + spec vacíos; tú pones el cuerpo |
dotnet watch run | hot reload, no reinicias el API |
Archivo .http + REST Client | probar la API sin Postman |
| Ctrl+. | quick-fix: agrega los using / import que falten |
| IntelliSense / autocompletado | obvio — no lo apagues por "pureza" |
Son 4 bloques que todo mundo copia. Saberlos de memoria = fluidez en la wiring:
// 1) connection string · appsettings.Development.json
"Sql": "Server=localhost\\SQLEXPRESS;Database=PruebaApi;Trusted_Connection=True;TrustServerCertificate=True"
// 2) DI de la conexión · Program.cs
builder.Services.AddScoped<IDbConnection>(_ => new SqlConnection(
builder.Configuration.GetConnectionString("Sql")));
// 3) CORS para el Angular de dev · Program.cs
builder.Services.AddCors(o => o.AddPolicy("dev", p =>
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
app.UseCors("dev"); // ANTES de app.MapControllers();
// 4) proxy de Angular · frontend/proxy.conf.json
{ "/api": { "target": "https://localhost:7xxx", "secure": false, "changeOrigin": true } }
Instalar (una vez): dotnet new install .\plantilla-dapper-slice y .\plantilla-dapper-entity.
dotnet new dapper-slice -n Tienda --entity Product → solución + ProductRepository, ProductsController (api/products), ProductsApiTests, el repo del reporte, wiring. Cuerpos vacíos. Plural derivado (--entity Order → Orders).dotnet new dapper-entity --entity Order --ns Tienda.Api (desde Tienda.Api/, N veces) → stubs de otra tabla. Program.cs registra los *Repository por convención, no lo tocas.✅ úsalo Comando o texto repetitivo (nadie lo escribe a mano):
| Snippet | Da | Rellenas |
|---|---|---|
!fact | [Fact] + la llamada + el assert | nombre, verbo, URL, código |
!httpblk | un bloque ### del .http | descripción, código, verbo, path, body |
!proxy | proxy.conf.json de una línea | el puerto del API |
!route | routes con loadComponent | nada |
🧠 de memoria Lo que evalúan — tecléalo tú, el snippet es para practicar: !repo (los 5 métodos del repo Dapper), !join (el INNER JOIN de 3 tablas — el que decide, apréndelo en blanco), !crud (los 5 verbos del controller), !svc / !page / !pagehtml (el servicio y la pantalla Angular). Detallados paso a paso en Tienda de prueba.
!repo, !join, !crud, !svc, !page) para el slice: escriben justo lo que hay que demostrar. Tecléalos.Program.cs, el repositorio, el controller o el componente desde un gist. El punto es escribirlos y decidir sobre la marcha.Objetivo: teclearlo fluido, explicando cada paso. Los contenidos de cada archivo están en Tienda de prueba — hasta que salgan sin mirar. Aquí va el orden y el ritmo.
| min | Tecleas | Explicas |
|---|---|---|
| 0:00 | Repites el requerimiento + 1–2 preguntas para acotarlo | "Confirmo que…" |
| 0:02 | dotnet new dapper-slice -n Tienda --entity Product (+ dapper-entity por cada tabla extra) · dotnet build | "template de equipo: esqueleto + wiring; los cuerpos vacíos, los escribo yo" |
| 0:05 | SSMS: CREATE DATABASE + las 4 tablas (Product con CHECK/UNIQUE; Customer/Order/OrderLine con FKs) + seed — el script del Paso 1 de Tienda de prueba | "el invariante lo pongo en la BD, no solo en C#" |
| 0:08 | revisas Program.cs (ya lo trae el template): conexión scoped, CORS dev, repos por convención · ajustas el Database= de appsettings.Development.json | "conexión por request; CORS solo para localhost:4200; cualquier *Repository se registra solo" |
| 0:11 | Models.cs: DTO + ProductInput con DataAnnotations | "validación de forma en el input → 400 automático" |
| 0:15 | ProductRepository.cs: SQL a mano, parámetros, QueryMultiple (lista + count) | "página y COUNT en una sola ida" |
| 0:22 | ProductsController.cs: los 5 verbos con sus códigos | "201+Location, 204, 404 si afecta 0 filas" |
| 0:26 | dotnet run → Swagger; pruebas GET, POST bueno, POST inválido | "antes de tocar el front, la API tiene que estar verde" |
| 0:29 | ProductsApiTests.cs: 3–4 tests (200, 404, 400, 201) · dotnet test | "cubro los casos límite" |
| 0:34 | ng new + ng add @angular/material (mientras compila, hablas) | "standalone + Material, paginación server-side" |
| 0:38 | proxy.conf.json + provideHttpClient() + ProductService | "proxy para pegarle a /api sin URLs absolutas" |
| 0:43 | ProductsPageComponent: mat-table + mat-paginator + form reactivo + MatSnackBar | "mismo límite de validación que el back" |
| 0:50 | ng serve --proxy-config → validas en el navegador; 1 test del servicio | "listo — de la tabla al CHECK de la BD, todo conectado" |
~50 min para el slice base completo, tecleado a mano. Con menos tiempo se acota el alcance — igual se empieza por la BD y se sube.
El caso típico no es "hazme la app": es "ya que tienes esto, agrega X". Practica estos tres hasta hacerlos en el tiempo dado, explicando. Código exacto por capa.
Price con validación (~12 min)ALTER TABLE dbo.Product ADD Price DECIMAL(10,2) NOT NULL
CONSTRAINT DF_Price DEFAULT (0) CONSTRAINT CK_Price CHECK (Price > 0);
UPDATE dbo.Product SET Price = 99 WHERE Price = 0; -- filas viejasProductDto: agrega , decimal Price. ProductInput: agrega
[Range(0.01, 1000000, ErrorMessage = "Precio > 0")] public decimal Price { get; set; }SELECT (..., Stock, Price), el INSERT ((Sku, Name, Stock, Price) VALUES (@Sku, @Name, @Stock, @Price)) y el UPDATE (SET ..., Price=@Price)..http, POST con "price": 0 → 400; con "price": 149.9 → 201.Product y ProductInput: agrega price: number;cols mete 'price' antes de 'acciones'; en form agrega price: [0, [Validators.required, Validators.min(0.01)]],<ng-container matColumnDef="price">
<th mat-header-cell *matHeaderCellDef>Precio</th>
<td mat-cell *matCellDef="let p">{{ p.price | currency:'MXN' }}</td>
</ng-container>
<mat-form-field>
<mat-label>Precio</mat-label>
<input matInput type="number" step="0.01" formControlName="price">
<mat-error>Mayor a 0</mat-error>
</mat-form-field>ProductsApiTests.cs agrega
[Fact] public async Task Post_precio_cero_400()
=> (await _c.PostAsJsonAsync("/api/products",
new { sku = "SKU-P0", name = "X", stock = 1, price = 0 }))
.StatusCode.Should().Be(HttpStatusCode.BadRequest);
y en product.service.spec.ts, en el test de create(), cambia el dto a { sku:'SKU-9', name:'Zinc', stock:3, price:50 } y agrega expect(r.request.body.price).toBe(50);dotnet test && ng test --watch=false → verde.Frase: "de abajo hacia arriba: el CHECK en la BD, el DTO para el 400 automático, el SQL del repo, verifico en Swagger, y recién ahí subo al front con el mismo límite en el validador."
Sku ya es UNIQUE → indexado).public Task<IEnumerable<ProductDto>> SearchAsync(string term) => db.QueryAsync<ProductDto>(
"SELECT ProductId, Sku, Name, Stock FROM dbo.Product WHERE Sku LIKE @p ORDER BY Sku",
new { p = term + "%" });[HttpGet("search")]
public async Task<ActionResult<IEnumerable<ProductDto>>> Search([FromQuery] string sku)
=> Ok(await repo.SearchAsync(sku ?? ""));
200 + lista vacía si no hay match. Nunca 404 para "sin resultados".GET /api/products/search?sku=SKU-00 → 200 con varios; ?sku=zzz → 200 y [].search(term: string) {
return this.http.get<Product[]>(`${this.base}/search?sku=${encodeURIComponent(term)}`);
}// imports: import { FormControl } from '@angular/forms';
// import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
searchCtrl = new FormControl('', { nonNullable: true });
ngOnInit() {
this.load();
this.searchCtrl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(t => t ? this.api.search(t) : this.api.list(0, this.pageSize)
.pipe(/* map a items */)),
).subscribe(rows => this.rows = rows as any);
}
(para simplificar, si t está vacío puedes solo llamar this.load() en un tap y filtrar el flujo.)<mat-form-field>
<mat-label>Buscar por SKU</mat-label>
<input matInput [formControl]="searchCtrl">
</mat-form-field>it('search() arma la URL', () => {
svc.search('vi').subscribe();
const r = http.expectOne('/api/products/search?sku=vi');
expect(r.request.method).toBe('GET');
r.flush([]);
});dotnet test && ng test --watch=false.Frase: "switchMap cancela la búsqueda anterior si el usuario sigue tecleando; debounceTime(300) para no pegarle al server en cada letra. Búsqueda sin resultados es 200 con lista vacía, no 404."
ALTER TABLE dbo.Product ADD IsActive BIT NOT NULL CONSTRAINT DF_IsActive DEFAULT (1);GetPagedAsync: los dos SELECT (lista y COUNT) agregan WHERE IsActive = 1.GetByIdAsync: ... WHERE ProductId = @id AND IsActive = 1.DeleteAsync: pasa a
db.ExecuteAsync(
"UPDATE dbo.Product SET IsActive = 0 WHERE ProductId = @id AND IsActive = 1",
new { id });DELETE ahora "desactiva").DELETE /api/products/2 → 204; GET /api/products/2 → 404; la lista ya no lo trae; DELETE otra vez → 404 (idempotente, afecta 0 filas).[Fact] public async Task Delete_es_logico()
{
var input = new { sku = "T" + Guid.NewGuid().ToString("N").Substring(0, 8), name = "X", stock = 1 };
var created = await (await _c.PostAsJsonAsync("/api/products", input))
.Content.ReadFromJsonAsync<ProductDto>();
(await _c.DeleteAsync("/api/products/" + created!.ProductId))
.StatusCode.Should().Be(HttpStatusCode.NoContent);
(await _c.GetAsync("/api/products/" + created.ProductId))
.StatusCode.Should().Be(HttpStatusCode.NotFound);
}dotnet test.Frase: "borrado lógico para no perder historial ni romper FKs. El GET filtra por IsActive, y el DELETE queda idempotente: la segunda llamada da 404 porque afecta 0 filas."
| Síntoma | Causa | Fix |
|---|---|---|
Navegador: CORS / blocked by CORS policy | falta CORS o el proxy | app.UseCors("dev") va antes de MapControllers(); y arranca Angular con --proxy-config proxy.conf.json |
| Primer GET → 500 | connection string / SQL apagado / BD no creada | revisa appsettings.Development.json, que el servicio SQL Server corra, que exista PruebaApi |
dotnet test no compila | falta public partial class Program { } o el reference | agrégalos; dotnet add Tienda.Api.Tests reference Tienda.Api |
Angular: NullInjectorError: HttpClient | falta provideHttpClient() | mételo en app.config.ts |
| Material se ve sin estilos | ng add @angular/material no terminó bien | revisa que styles.scss tenga el bloque de tema que agrega el schematic |
| El proxy no conecta | puerto de proxy.conf.json ≠ puerto del API | copia el puerto de la consola de dotnet run ("Now listening on...") |
| Tests de integración: "content root" o 404 en todo | WebApplicationFactory no halla el API | que el proyecto de tests tenga el reference al API y el paquete Mvc.Testing |
Meta: teclear el slice base a mano en ~45 min sin trabarte en la wiring, y resolver un ejercicio en ~15, hablando todo el tiempo. Que se note que saldría igual de bien en cualquier codebase.
Todas las frases, sin relleno. Una idea por línea, en el orden en que salen.
UNIQUE en SKU, CHECK en stock, las FOREIGN KEY — no solo en C#."OrderDate descendente porque el reporte ordena y pagina por fecha."Input lo que entra. Con [Required]/[Range], si el body no cumple ASP.NET devuelve 400 sin un if mío."COUNT(*) en una sola ida con QueryMultiple."Create: INSERT + SELECT CAST(SCOPE_IDENTITY() AS int) para devolver el id nuevo."Update y Delete devuelven filas afectadas: 0 = no existe = 404."DynamicParameters sobre el input para no repetir la lista de campos."Location vía CreatedAtAction, o 400. PUT y DELETE → 204 sin body, o 404."DataAnnotations."ExecuteAsync afecta 0 filas — hace el DELETE idempotente."WebApplicationFactory: levanta la API real en memoria. Cubren 200, 404, 400 y el 201 de punta a punta."Quantity * UnitPrice."OrderLine, la tabla base, el grano del reporte."Order por OrderId, Customer por CustomerId, Product por ProductId; INNER porque solo quiero líneas completas.">= @fromDate AND < @toExclusive, nunca BETWEEN — incluiría el límite y se rompe con horas."ORDER BY determinista que termina en columna única — OFFSET/FETCH lo exige o la paginación baila."OFFSET/FETCH; el COUNT con el mismo WHERE en la misma ida."CAST ni CONVERT: SARGable, usa el índice."toExclusive."SELECT: no necesita transacción explícita."Order + sus OrderLine + descontar stock— la envuelvo en una transacción para que sea atómica: o todo o nada."connection.BeginTransaction(), lo paso a cada Execute, Commit() al final, Rollback() en el catch."READ COMMITTED) suele bastar; subiría a SERIALIZABLE solo si necesito que nadie inserte en el rango que estoy leyendo mientras decido."/api/* al backend; el servicio usa rutas relativas, sin líos de CORS."interface son el contrato — mismos nombres que el JSON de .NET, en camelCase."MatSnackBar."HttpTestingController: por cada método verifico URL, verbo y body, sin tocar la red."AddScoped<IDbConnection>; Dapper la usa por request."/api/* al puerto del API."localhost:4200, antes de MapControllers."CHECK de la base, cada capa conectada y probada por separado antes de unirla."dotnet new de equipo. Dejan estructura y wiring; los cuerpos vacíos. El scaffolding con tooling estándar; la lógica la escribo yo."*Repository del ensamblado entra como Scoped."Resumen de cómo funcionan las dos piezas de tooling, por si preguntan.
Un archivo dapper-slice.code-snippets (JSON) en Configure Snippets ▸ New Global Snippets file. Cada entrada tiene un prefix (lo que escribes) y un body (líneas de texto). Se necesita "editor.tabCompletion": "onlySnippets" en settings.json para que el Tab expanda.
| Pieza | Qué es |
|---|---|
${1:Texto} | un tab-stop con valor de ejemplo. Al expandir queda seleccionado: Tab lo acepta, o escribes encima. |
${1:...} repetido | espejo: el mismo número en varios sitios se actualiza junto. Así !repo cambia Product → Customer en 5 lugares de un tecleo. |
${2/([A-Za-z_]\w*)/@$1/g} | transformación: aplica un regex al valor del tab-stop. En !repo, de Sku, Name, Stock saca @Sku, @Name, @Stock y Sku = @Sku, … sin volver a escribirlos. |
$0 | dónde queda el cursor al final. |
Por eso !repo pide solo 2 datos (entidad + columnas): todo lo demás se deriva con espejos y transformaciones.
dotnet newUna carpeta con los archivos del proyecto + .template.config/template.json. dotnet new install <carpeta> la registra; dotnet new <shortName> la usa.
Clave de template.json | Qué hace |
|---|---|
sourceName: "Slice" | el texto que -n Tienda reemplaza en nombres de archivo y contenido. |
symbols.entity (parameter) | expone --entity; replaces + fileRename cambian Product → lo que pases, en archivos y código. |
symbols.entityPlural (generated · regex) | deriva el plural de entity con reglas (s/x/z→es, cons+y→ies, resto +s): Category → Categories. |
symbols.entityPluralLower (generated · casing) | minúsculas para la ruta api/…. |
Dos plantillas: dapper-slice (tipo solution) crea todo el proyecto; dapper-entity (tipo item) agrega los stubs de una entidad a un proyecto existente y se corre N veces. --ns es obligatorio en dapper-entity porque dotnet new por CLI no lee el namespace del .csproj.
Registro por convención: el Program.cs de la plantilla recorre el ensamblado y registra como Scoped todo tipo que termina en Repository — así agregar entidades no lo toca.
"Son snippets y un par de plantillas dotnet new de equipo. Los snippets usan tab-stops con espejo y una transformación regex, así con la entidad y la lista de columnas se arma el resto. Las plantillas dejan estructura y wiring; los cuerpos vacíos. El scaffolding lo hago con tooling estándar; la lógica la escribo yo."
Pregunta 4: "tu nube es AWS Lambda, no Azure: ¿qué esperas que cambie?" Buena señal si nombras lo que NO equivale. No finjas experiencia en Azure que no tienes; demuestra que sabes traducir conceptos y dónde están las trampas.
| AWS | Azure | ¿Equivale limpio? |
|---|---|---|
| Lambda | Azure Functions | Parcial — Functions usa bindings declarativos (input/output por atributos); en Lambda escribes las llamadas al SDK a mano. Modelo isolated worker en .NET. |
| API Gateway | Azure API Management / HTTP trigger + Front Door | No — APIM es más pesado y caro; para casos simples basta el HTTP trigger de la Function. |
| Step Functions | Durable Functions / Logic Apps | No — Step Functions es una máquina de estados en JSON; Durable Functions es orquestación en código C#. Mentalidad distinta. |
| DynamoDB | Cosmos DB | No — Cosmos es multi-modelo, otro modelo de particionado y de costo (Request Units). |
| S3 | Blob Storage | Sí, cercano. |
| SQS / SNS | Storage Queues / Service Bus / Event Grid | No 1:1 — Service Bus ≈ SQS+SNS (colas + topics, sesiones, dead-letter); Storage Queues es lo simple; Event Grid es push de eventos. Son 3 cosas, no 1. |
| CloudWatch | Application Insights / Azure Monitor | App Insights es más rico para .NET (tracing distribuido casi gratis). |
| IAM roles | Managed Identity + RBAC (+ a veces App Registrations en Entra ID) | No — otro modelo mental de identidad y permisos. |
| SAM / CDK / Serverless Framework | Bicep / ARM / azd, y Azure DevOps Pipelines (ellos) | No — IaC y CI distintos; ellos ya usan Azure DevOps. |
"Mi experiencia de nube es AWS Lambda, así que lo que traigo es el modelo serverless: funciones stateless, cold starts, pensar en idempotencia y en límites de ejecución. Al pasar a Azure, lo que sí traslada casi directo es el enfoque event-driven, Blob ≈ S3, y App Insights me daría más de lo que tenía con CloudWatch. Lo que no equivale y tendría que aprender: Azure Functions usa bindings declarativos en vez de llamar al SDK a mano; Durable Functions no es Step Functions —es orquestación en código, no una máquina de estados JSON—; Cosmos no es DynamoDB en particionado ni en costeo; y la identidad pasa de IAM roles a Managed Identity + RBAC. Además, para el grueso de una API .NET probablemente no sería Functions sino App Service o Container Apps — ¿qué usan ustedes?"
Esa última pregunta te posiciona como alguien que piensa en arquitectura, no solo en servicios sueltos.
"Integrar nuevos proveedores externos tomando ownership de toda la capa." Ten estos patrones listos:
payment_intent.succeeded): verificar firma, responder 2xx rápido, procesar async, tolerar entregas duplicadas y fuera de orden.Stripe.net.Cada pregunta: por qué la hacen, la estructura de tu respuesta, un borrador con tu dato marcado, qué recalcar y qué evitar. Practica en voz alta y cronometrada. No memorices palabra por palabra — con la estructura clara improvisas.
Lo que necesito de ti para cerrar los borradores (donde falta va [RELLENA]):
| Letra | Qué es | Cuánto |
|---|---|---|
| Situación | 1 frase de contexto: dónde, cuándo, qué sistema. | ~10 s |
| Tarea | qué tenías que lograr y por qué importaba. | ~10 s |
| Acción | lo que hiciste tú, en primera persona, concreto, con la decisión difícil. | ~40 s (el grueso) |
| Resultado | qué pasó, con número si puedes, y qué aprendiste / cambiaste. | ~15 s |
Regla de oro: la A es donde te evalúan. Di "yo diseñé / yo decidí / yo escribí", nunca "el equipo hizo". 60–90 s por historia; si te pasas de 2 min, te cortan.
Por qué: marca el tono y te da el control de la narrativa. La pregunta de SQL viene justo después — este pitch la desactiva si nombras SQL Server tú mismo.
Estructura (4 movimientos, 60–90 s): quién eres + dónde → dónde tienes más profundidad (frontend) → tu otro lado (backend + nube, sin esconder nada) → qué buscas y por qué encajas.
"Soy desarrollador full-stack con 5 años en [SEUS], en un POS y backoffice para retail farmacéutico — hoy 18+ módulos en producción: compra directa, recepción electrónica, monitor de embarques.
Donde tengo más profundidad es en frontend: Angular —ahora en 20—, RxJS, Angular Material, y migramos a microfrontends con Native Federation, donde fui el mayor contribuidor del repo con 86 commits. Testeo con Karma/Jasmine y con Jest.
Del lado del backend trabajo .NET con REST y SQL Server, con el acceso a datos escrito a mano. Mi experiencia de nube es AWS Lambda.
Busco un puesto full-stack de verdad, remoto, con ownership de features completas — y su stack es casi el mismo en el que ya me muevo, así que contribuyo rápido y a la vez profundizo más en .NET y datos."
Recalca: di "5 años" (es lo del CV) · menciona SQL Server tú mismo · no escondas el inglés básico ni el AWS.
Evita: recitarlo monótono · pasar de 90 s · empezar por la vida personal · "soy apasionado de la tecnología".
Por qué: filtran a quien aplica a todo. Quieren 2–3 razones específicas de ellos.
Estructura: el trabajo (encaja con cómo ya trabajas) → el stack (casi idéntico) → el producto (real, equipo chico, impacto) → dónde quieres crecer (su lado fuerte) → remoto.
"El modelo que describen —feature vertical completa, ownership de la capa de integración— es exactamente cómo ya trabajo. El stack coincide casi campo por campo: Angular 20, RxJS, Material, microfrontends; no pierdo tiempo de arranque. Es e-commerce con producto y usuarios reales, y equipo chico, o sea impacto directo. Y me interesa profundizar justo en .NET + SQL Server e integraciones —Stripe, Shopify, SP-API—, que es donde quiero crecer. Más que es remoto, que es lo que busco."
Evita: "me gusta su cultura" / "es una empresa en crecimiento" — vacío. Nada que aplique a cualquier empresa.
Por qué: el puesto es eso: modelo de datos → servicio → endpoint → componente.
S: En el POS/backoffice farmacéutico, el módulo de [recepción electrónica / compra directa / monitor de embarques — elige uno].
T: necesitaba [RELLENA: qué problema de negocio resolver], y no había nadie más asignado.
A: Diseñé el modelo de datos en SQL Server, escribí el acceso a datos y el servicio .NET, expuse el REST y construí el componente Angular con Material. [RELLENA: 1 decisión técnica difícil — p. ej. denormalizar por rendimiento, un índice, cómo modelé el estado].
R: Salió a producción y [RELLENA: métrica — X horas/semana ahorradas, Y% menos errores, Z pedidos/día procesados]. Hoy es uno de los 18+ módulos vivos y le sigo dando mantenimiento.
Recalca: las cuatro capas, en primera persona. Evita: quedarte en "diseñé la arquitectura" sin bajar a una decisión concreta.
Por qué: corren 18 módulos vivos; "experiencia de compra confiable" es un valor declarado.
S/T: [RELLENA: el bug — qué se rompió y a quién afectó].
A: detección ([¿monitoreo? ¿reporte de usuario?]) → aislé la causa raíz ([RELLENA]) → fix inmediato → y la parte que importa: agregué un test de regresión ([Karma / Jest]) y [cambio de proceso: una validación, una alerta, un paso de revisión en PR].
R: no volvió a pasar; el test lo cubre desde entonces.
Recalca: el 70% de la respuesta es la prevención, no el susto. Evita: culpar a otro; contar el bug con dramatismo y saltarte el "qué cambié".
Por qué: el core del puesto es integrar Stripe / Shopify / SP-API.
Si tienes la historia: [RELLENA: qué proveedor, qué falló — timeouts, rate limit, datos inconsistentes, webhooks duplicados] → cómo lo hiciste resiliente: reintentos con backoff, idempotency key, conciliación periódica.
Si NO la tienes: dilo y pivota — "proveedor de pagos como tal no he integrado; así lo abordaría" y explicas idempotency + outbox + webhooks firmados + circuit breaker (pestaña Cloud). Honesto y demuestra criterio.
Evita: inventar una integración que no hiciste — se cae con una repregunta.
Por qué: quieren saber cómo entras a algo desconocido — como su codebase.
Historia natural: Native Federation. [RELLENA: por qué migraron a microfrontends, qué plazo tenías] → lo estudié desde los import maps y el manifest → migré [N] módulos → resolví los dolores reales: version skew (dos instancias de Angular, NG0203), comunicación entre microfrontends. R: deploy independiente por equipo.
Recalca: que nombras dolores concretos — eso prueba que lo hiciste, no que leíste el README.
Por qué: miden si eres rígido o colaborativo, y si cambias de opinión con datos.
S/T: [RELLENA: la decisión — p. ej. Karma vs Jest, estructura de un módulo, EF vs SQL a mano].
A: expuse mi posición con datos ([RELLENA: qué medí o comparé]) → escuché su argumento → [acordamos X / probamos un spike / cedí en Y porque tenía razón en Z].
R: [qué salió], y aprendí [qué de la otra postura].
Recalca: priorizas el resultado del equipo por encima de tener la razón. Evita: una historia donde tú tenías razón y ya.
Por qué: el JD pide "traducir requerimientos de negocio a specs técnicas".
S/T: [RELLENA: un requerimiento ambiguo que te llegó].
A: hice preguntas para acotarlo ([qué preguntaste]) → lo convertí en una spec con casos y criterios de aceptación → lo validé con quien lo pidió antes de construir.
R: se construyó una vez, sin retrabajo.
Recalca: "pregunto antes de asumir" — es literal lo que evalúan en la prueba técnica también.
"Es cierto que no lo destaqué, y debí hacerlo: el acceso a datos en SQL Server es parte de mi día a día en el backend —consultas, joins, transacciones, todo escrito a mano—. Lo dejé implícito bajo '.NET / backend' y fue un error de redacción del CV, no de experiencia. De hecho por eso agradezco la prueba práctica."
Recalca: "error de redacción, no de experiencia" + agradecer la prueba. Evita: justificarte largo o sonar a la defensiva. Dos frases y adelante.
"Mi inglés es [básico / básico-intermedio — sé honesto] de lectura. Consumo documentación técnica en inglés —la de Angular ya así— y me apoyo en traducción con texto denso. No lo hablo con fluidez todavía; lo trabajo activamente [práctica diaria — Anki / una app]. Para comunicación escrita asíncrona me defiendo; para una reunión hablada en inglés todavía no. Sé que SP-API, Stripe y Angular están documentados en inglés y con eso puedo."
Evita: exagerar (lo testean en la misma llamada) · disculparte tres veces. Una respuesta clara + el plan de mejora.
| Lo que dudan | Encuadre (di esto, con tu verdad) |
|---|---|
| Un solo empleador toda la carrera | "No cambié de empresa porque no dejé de crecer dentro: pasé de [rol inicial] a mayor contribuidor del repo y dueño de módulos críticos. Lo que sí cambió constantemente fue el stack debajo de mí — Angular 17→20, adoptar Native Federation, dos frameworks de test. Ahora busco ese cambio de entorno a propósito." |
| Pasante sin titular desde 2021 | Factual y hacia adelante: "[RELLENA tu situación real]. En estos años el trabajo real ha sido mi formación: 18 módulos en producción lo respaldan." No te disculpes, no te extiendas. |
"Entré a un repo grande y en 4 años me volví su mayor contribuidor; sé leer código ajeno y moverme en un dominio complejo — retail farmacéutico con 18 módulos y sus reglas. Un codebase nuevo es la misma habilidad: primero leo el flujo de una feature de punta a punta, luego toco."
Cuatro razones, en positivo, sin quejarte del empleo actual: crecimiento (quiero profundizar en .NET/datos/integraciones) · ownership full-stack real · remoto · el stack encaja. Nunca "estoy harto de…" ni nada negativo de SEUS.
Las de escribir SQL están en SQL · paso a paso y Tienda de prueba. Estas son para decirlas en < 1 min, con estructura.
Estructura: 3 diferencias que importan → dónde aparece en Angular → el riesgo y cómo lo manejas.
"Una promesa es un valor único, eager —corre al crearse— y no cancelable. Un Observable es un flujo perezoso: no hace nada hasta que te suscribes, puede emitir muchos valores en el tiempo, y lo cancelas con unsubscribe. En Angular importa porque HttpClient devuelve Observables: desuscribirte cancela la petición de verdad. Y tengo operadores —switchMap, debounceTime— para coordinar eventos. El riesgo es olvidar desuscribirte; lo resuelvo con el pipe async o takeUntilDestroyed."
Si preguntan por los 4 de aplanado: switchMap cancela el anterior (búsqueda) · mergeMap paraleliza · concatMap encola en orden (guardados) · exhaustMap ignora los nuevos mientras trabaja (botón confirmar, anti doble-submit).
Dibujo: shell (host) → lee federation.manifest.json → genera import map → carga el remoteEntry de cada microfrontend bajo demanda → monta su ruta. Deps compartidas (Angular, RxJS, Material) resueltas una vez.
Aporta: deploy independiente por microfrontend · integración en runtime · deps compartidas · compatible con esbuild (webpack MF ya no).
Cuesta (en 3 cubos): (1) version skew → dos instancias de Angular, NG0203, se mitiga con shared: { singleton: true, strictVersion: true } · (2) comunicación/estado entre microfrontends: sin solución nativa · (3) tooling: routing, dev local con todos los remotos, debugging opaco, testing cruzando la frontera, SSR más débil.
Recalca: nombrar los dolores concretos es lo que prueba que lo usaste de verdad.
Estructura: lo que SÍ traslada → lo que NO equivale (aquí ganas puntos) → una pregunta de arquitectura.
"Lo que traigo es el modelo serverless: funciones stateless, cold starts, idempotencia, límites de ejecución. A Azure traslada casi directo el enfoque event-driven, Blob ≈ S3, y App Insights me daría más que CloudWatch. Lo que no equivale y tendría que aprender: Azure Functions usa bindings declarativos en vez de llamar al SDK a mano; Durable Functions no es Step Functions —es orquestación en código C#, no una máquina de estados JSON—; Cosmos no es DynamoDB en particionado ni en costeo; y la identidad pasa de IAM roles a Managed Identity + RBAC. Además, para una API .NET grande probablemente no sería Functions sino App Service o Container Apps — ¿qué usan ustedes?"
Tu anécdota (rellena con un test real): componente [nombre] → lo testeaba por [regresión / feature] → comprobaba [render condicional / emisión de @Output / que llamó al servicio con los args correctos] → setup con TestBed + mock con jasmine.createSpyObj → async con [fakeAsync+tick / HttpTestingController].
Karma vs Jest: Karma corre en navegador real (Chrome headless), Jest en jsdom (Node, más rápido). Tienen los dos: Jest para el ciclo rápido de TDD/CI, Karma para fidelidad de navegador real. "¿Los unificarías?" → depende del costo de mantener dos configs vs. lo que se perdería; postura defendible: todo lo unitario a Jest, Karma/Playwright solo para lo que exige navegador.
Pregunta su rango primero, y el esquema (nómina / honorarios / contractor) y moneda. Si insisten en que des número: 65,000–85,000 MXN mensuales, ajustable según esquema y prestaciones. El piso que dices (65k) es tu objetivo, nunca menciones tu mínimo. Detalle completo en Negociación.
"Disponible de inmediato" ya lo tienen en el CV. Si sigues en SEUS, aclara tu tiempo de aviso real ([RELLENA: 2 semanas / 1 mes]).
"Más profundidad full-stack —sobre todo del lado de .NET, datos y arquitectura de integraciones—, y con el tiempo, si el equipo lo necesita, tomar decisiones técnicas de más alcance o mentorear." Concreto y alineado a un equipo chico. Nada de "tener tu puesto".
Al tech lead:
Al hiring manager / producto:
La última resuelve el hueco del toolchain y del formato de la prueba — si no te lo han dicho, te lo dicen ahí.
Sobre los datos de banda salarial: Levels.fyi y similares casi no tienen data de e-commerce pequeño en México, y NEWCO es una LLC de EE. UU. contratando remoto en MX — el esquema (nómina local, honorarios, contractor en USD) cambia el número por completo. Lo de abajo es estimación de mercado, no un dato duro. Confírmalo preguntando su rango.
| Escenario | Rango mensual aprox. (MXN) |
|---|---|
| Nómina local, empresa no-tech pequeña | 40,000 – 65,000 |
| Honorarios / contractor, pago influido por USD | 55,000 – 90,000+ |
| Tu objetivo declarado | hasta 80,000 |
Dado que es una LLC de EE. UU. y el puesto es "otro tipo de contrato" (probablemente honorarios/contractor), es razonable anclar en la parte alta.
Cuando pregunten "¿cuál es tu expectativa?"
"Antes de dar un número me ayudaría saber el esquema: ¿es nómina, honorarios, o contractor? ¿el pago es en pesos o en dólares? ¿qué prestaciones incluye? Con eso puedo darte una cifra que tenga sentido. ¿Cuál es el rango que tienen contemplado para la posición?"
Si insisten en que des número primero
"Con base en mi experiencia —5 años full-stack, Angular a nivel senior, microfrontends, y la parte de .NET y SQL Server que es justo lo que buscan— mi expectativa está en el rango de 65,000 a 85,000 MXN mensuales, ajustable según esquema y prestaciones."
El piso que dices (65k) es tu objetivo, no tu mínimo. Nunca ancles en 35k.
Contraoferta si ofrecen bajo
"Agradezco la oferta y el puesto me interesa mucho. El número está por debajo de lo que esperaba para este alcance —soy full-stack con ownership de la capa de integración y Angular senior—. ¿Hay margen para llegar a [tu cifra]? Si el fijo tiene tope, ¿podemos ver bono por desempeño, revisión a los 6 meses, o días de vacaciones?"
Formato confirmado: fichero .cs suelto, 3 piezas (SQL + método + endpoint) + Angular hablado. No compila nada. El guion completo está en la pestaña La prueba; este plan es el calendario para dominarlo.
Bucle de práctica (Escritorio): doble clic en reset-prueba.bat → deja prueba.cs en blanco y lo abre. Tecleas la solución narrando, cronómetro corriendo. Corres verificar.sql en SSMS/Azure Data Studio contra PruebaCocoMarch para comprobar. Repites. reset-bd.bat si ensayaste INSERTs.
reset-prueba.bat, y teclea el esqueleto + la consulta mirando la biblia. Corre verificar.sql.verificar.sql → confirma las 22 líneas de enero, la frontera (pedido 16 entra, 17 no), la página 2 sin repetir.BETWEEN, por qué sin CAST (SARGable), por qué el ORDER BY termina en columna única, OFFSET vs keyset.const string → método (QueryMultiple, leer, devolver) → endpoint (validar, sanear, delegar). Todo en prueba.cs.GROUP BY, uno con objeto de query). Que el read-back y el mapeo salgan solos.READ COMMITTED / el matiz de los dos SELECT / SNAPSHOT / escritura multi-tabla / SERIALIZABLE.LIKE, un INSERT con transacción).reset-prueba.bat deja prueba.cs listo. Ten a mano SSMS/Azure Data Studio conectado (para tu tranquilidad, aunque no lo uses en vivo).prueba.cs (ya reiniciado). Verifica una vez más que Claude Code y Copilot están desactivados.| Componente | Estado |
|---|---|
| .NET SDK | ✅ 9.0.317 |
| SQL Server | ✅ 2022 Express, instancia localhost\SQLEXPRESS, corriendo |
| Clientes SQL | ✅ SSMS 19 · Azure Data Studio · sqlcmd 16 |
| BD de práctica | ✅ PruebaCocoMarch — 3 tablas, poblada (10 productos, 20 pedidos, 37 líneas) |
| VS Code + C# Dev Kit | ✅ instalados (IntelliSense: abrir carpeta, no fichero suelto — poco relevante aquí porque no compila) |
| Scripts | ✅ datos-prueba.sql · verificar.sql · prueba.cs · reset-prueba.bat · reset-bd.bat |
Connection string para ensayar: Server=localhost\SQLEXPRESS;Database=PruebaCocoMarch;Trusted_Connection=True;TrustServerCertificate=True
Ownership de feature vertical = como ya trabajo · stack 90% igual al mío · producto real, equipo chico, impacto directo · quiero profundizar en .NET + integraciones · remoto.
>= @From AND < @ToExclusive — NUNCA BETWEEN. Nada de funciones sobre la columna.ORDER BY determinista + columna única de desempate, luego OFFSET/FETCH. Keyset para páginas profundas.(OrderDate DESC, OrderId DESC).SET XACT_ABORT ON + TRY/CATCH + IF XACT_STATE() <> 0 ROLLBACK + THROW.UPDLOCK · deadlock → orden consistente + retry 1205 · concurrencia optimista con rowversion.conn.QueryAsync<T>(sql, params), transacción con BeginTransactionAsync y pasar tx.Observable: flujo (0..∞) · lazy · cancelable · operadores · cold reejecuta. Promise: uno · eager · no cancelable · .then.
4 aplanados: switch=cancela (búsqueda) · merge=paralelo · concat=cola ordenada · exhaust=ignora mientras trabaja (submit).
Module Federation sobre ESM + import maps para esbuild. Aporta: deploy independiente, integración runtime, deps compartidas. Cuesta: version skew (dos Angular → NG0203), comunicación entre MFEs sin solución nativa, routing, dev local, debugging opaco.
Step Functions ≠ Durable Functions (JSON vs código C#) · DynamoDB ≠ Cosmos (particionado, RU) · IAM roles ≠ Managed Identity + RBAC · API Gateway ≠ APIM · Lambda→Functions con bindings declarativos · una API .NET grande probablemente va en App Service / Container Apps, no Functions.
"Básico de lectura, consumo docs técnicos en inglés, lo trabajo activamente a diario, no conversacional aún. Para SP-API/Stripe/Angular me alcanza."
Un empleador = crecí dentro sin dejar de hacerlo (rol inicial → mayor contribuidor + dueño de módulos). El stack cambió constantemente aunque la empresa no. Titulación: [tu frase real], factual y hacia adelante.
Pregunta su rango + esquema (nómina/honorarios) + moneda PRIMERO. Si insisten: 65,000–85,000 MXN. Nunca menciones 35k.
SQL Server + .NET SDK + Angular CLI ~20 montados y probados · solución scratch pre-creada · BD de práctica sembrada · screen share testeado · correo previo confirmando el entorno.