Preparación de entrevista — Full Stack Developer (Angular + .NET)

Empresa: NEWCO LLC · marca Vita Tienda de Cocó March · Remoto · Candidato: Roy García
Oferta Computrabajo 05F54C…3405 Salario: a convenir Prep generada 2026-08-28 Fuente clave: notas del entrevistador

Resumen ejecutivo

La entrevista se juega en UNA pregunta, y va primera.

Escribir a mano un JOIN de 3 tablas con filtro por fecha y paginación, y explicar la transacción. Tu CV no menciona SQL en ningún lado y para ellos el acceso a datos escrito a mano es "la mitad del trabajo". Si sale con soltura → subes al primer puesto de Full-Stack. Si no → deciden si te forman o te mueven a Front End Angular.

Riesgo operativo #1: el toolchain.

Confirmaste "equipo propio" pero no el entorno. Si llegas sin SQL Server + .NET SDK + Angular CLI montados y probados, la prueba técnica no se puede correr. Ver checklist en la pestaña Plan 5 días → Día 5. Esto es lo primero que debes cerrar.

Qué juega a tu favor

Qué van a apretar

  1. SQL Server — join + fecha + paginación + transacción (decide todo).
  2. Angular a fondo — Observable vs Promesa; qué te aportó y qué te costó Native Federation.
  3. Karma/Jasmine ejercido — el último test de componente que escribiste y qué comprobaba.
  4. AWS Lambda → Azure — qué cambia; buena señal si nombras lo que no equivale.
  5. Inglés — te lo van a preguntar sin rodeos (docs de SP-API, Stripe, Angular en inglés).
  6. Historia laboral — un solo empleador, pasante sin titular desde 2021.

5 focos de estudio priorizados

#FocoPor quéDónde
1Joins + paginación (OFFSET/FETCH y keyset) + transacciones en SQL ServerEs la pregunta que decidePestaña SQL
2Acceso a datos "a mano": Dapper / ADO.NET con IDbTransaction"La mitad del trabajo" según ellosPestaña SQL
3AWS→Azure: tabla de equivalencias y no-equivalenciasPregunta 4, tu punto débil de cloudPestaña Cloud
4Respuesta honesta y ensayada sobre tu inglésPregunta 5, la van a testearPestaña Guion
5Native Federation: beneficios y dolores concretos, poder dibujarloPregunta 2, tu fuerte — hay que lucirloPestañ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.

Dossier de la empresa

Qué es

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).

Modelo de negocio y arquitectura probable

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 Guion → preguntas que haces).

Stack (confirmado vs. inferido)

ÁreaTecnologíaConfianza
FrontendAngular 20.3, TypeScript, RxJS, Angular Material, BootstrapAlta (JD + notas)
Frontend arquitecturaMicrofrontends con Native Federation, federation.manifest.jsonAlta (notas)
Testing frontendKarma/Jasmine y JestAlta (notas)
Backend.NET / ASP.NET, REST APIAlta (JD)
Acceso a datosSQL Server (+ MySQL), escrito a mano — probablemente Dapper o ADO.NET, no EF pesadoMedia — preguntar cuál
Cloud / CIAzure, Azure DevOps (pipelines + boards)Alta (JD)
IntegracionesShopify Admin API, Amazon SP-API, StripeAlta (JD + notas)

Valores declarados (para las conductuales)

"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.

Entrevistadores probables

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á.

Huecos conocidos de este dossier

SQL Server — la pregunta que decide

"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.

Esquema de práctica (móntalo en tu SQL Server local y siembra datos)

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);

El JOIN de 3 tablas + fecha + paginación — modelo a memorizar

-- 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;

Lo que TIENES que decir mientras lo escribes (esto es lo que califican)

Si te piden el total de filas (para el paginador de la UI)
-- 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 ...;
La respuesta de escalado: paginación por keyset / seek (páginas profundas)

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".

La transacción — qué explicar

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

Conceptos que te van a picar sobre la transacción

ConceptoQué decir
ACIDAtomicidad (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 ONCon 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() / @@TRANCOUNTComprobar en el CATCH antes de hacer ROLLBACK. XACT_STATE() = -1 = transacción no confirmable, solo rollback.
Niveles de aislamientoDefault 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.
DeadlocksOrden de acceso consistente a las tablas en todos los procs; reintento en error 1205 desde la capa de aplicación (Polly).
Concurrencia optimistaColumna rowversion comprobada en el WHERE del UPDATE; si afecta 0 filas, alguien más ya cambió el registro. En EF Core: atributo [Timestamp].
Transacciones cortasNada 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ú.

Acceso a datos "escrito a mano" — patrones (ellos: "la mitad del trabajo")

Dapper (lo más común para SQL a mano en .NET)

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; }
ADO.NET crudo (por si lo piden sin librería)
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.

EF Core (por si usan un híbrido)
// 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.

Mini-drills (hazlos a mano y luego córrelos)

  1. Top 10 productos más vendidos (por unidades) en un rango de fechas — GROUP BY + SUM + ORDER BY + TOP.
  2. Pedidos sin ningún renglón — LEFT JOIN … WHERE ol.OrderLineId IS NULL o NOT EXISTS.
  3. Total facturado por cliente y mes — GROUP BY c.CustomerId, DATEFROMPARTS(YEAR(o.OrderDate), MONTH(o.OrderDate), 1).
  4. Clientes con más de 3 pedidos en el rango — HAVING COUNT(*) > 3.
  5. El proc CreateOrder completo, probando el rollback (mete un producto con stock 1 y pide 5).
  6. Reescribe la paginación de OFFSET a keyset.
  7. Explica un plan de ejecución: dónde hay Index Seek vs Scan y por qué.

SQL Server — paso a paso (para tontos)

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.

Paso 0 · Con qué trabajas

GO no es SQL: es una señal para el editor que dice "manda lo de arriba y sigue". Se usa entre bloques.

Paso 1 · Crear la base de datos

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;.

Paso 2 · Qué es una tabla (y los tipos de dato)

Una tabla es una hoja de Excel: filas y columnas. Cada columna tiene un tipo:

TipoGuardaEjemplo
INTenteros42
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 decimales15.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:

ReglaQué hace
PRIMARY KEYla 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 NULLobligatoria, no puede quedar vacía.
UNIQUEno 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.

Paso 3 · Crear las 4 tablas (en orden)

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".

Paso 4 · Llenar de información (sembrar / "seed")

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;

Paso 5 · El JOIN de 3 tablas, línea por línea

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.

Los 3 puntos que te CALIFICAN (dilos en voz alta)

  1. Rango de fecha semiabierto, no 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.
  2. No envuelvas la columna en función. WHERE CAST(o.OrderDate AS date) = … anula el índice. Filtra contra la columna cruda. La palabra elegante: predicado SARGable.
  3. ORDER BY determinista. 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.

Probar la paginación

-- 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

La versión con parámetros (la de verdad)

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;

Paso 6 · La transacción, para tontos

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.

La versión mínima (el concepto)

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)

La versión buena: TRY/CATCH + control de errores

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íneaExplicación de tonto
SET NOCOUNT ON"no me mandes 'X rows affected' por cada insert". Limpia ruido, ahorra tráfico.
SET XACT_ABORT ONel 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, '...', 1lanzar 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.

Probar el ROLLBACK (que veas que funciona)

-- 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).

Angular a fondo — tu fuerte, hay que lucirlo

1 · Observable vs Promesa

PromiseObservable
Valoresuno solo0..∞ (un flujo en el tiempo)
Ejecucióneager: corre al crearselazy: corre al hacer subscribe()
Cancelablenosí: unsubscribe() aborta (p. ej. cancela el HTTP)
Operadores.then()/.catch()pipe(map, filter, switchMap, debounceTime, retry, combineLatest…)
Sync/asyncsiempre async (microtask)puede emitir sync o async
Multi-suscriptorcomparte el único resultadocold: 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.

Los 4 operadores de aplanado — sé capaz de dar el caso de uso de cada uno

OperadorComportamientoCaso real en un POS / backoffice
switchMapcancela la anteriorbúsqueda de producto: solo importa la última tecla
mergeMaptodas en paralelodisparar N validaciones independientes a la vez
concatMapcola, en ordenguardar renglones de un pedido en secuencia sin pisarse
exhaustMapignora nuevas mientras trabajabotó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).

2 · Native Federation — qué te aportó y qué te costó

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).

Qué aporta (beneficios que viviste)

Qué cuesta (dolores concretos — esto es lo que confirma que lo usaste de verdad)

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.

3 · Otros temas que pueden caer (tu fuerte, respuestas cortas)

Angular a fondo — paso a paso (para tontos)

Los mismos temas de la pestaña Angular, pero con el modelo mental masticado y la frase exacta que dices en la entrevista.

1 · Observable vs Promesa

El modelo mental

PromiseObservable
¿Cuántos valores?uno0, 1 o muchos (un flujo en el tiempo)
¿Cuándo arranca?eager: al crearla ya corriólazy: nada hasta .subscribe()
¿Cancelable?nosí: unsubscribe(). En HTTP cancela la petición de verdad
¿Transformar?.then() / .catch().pipe(map, filter, switchMap, debounceTime…)
¿Sync/async?siempre asyncpuede emitir sync o async
Varios suscriptorescomparten el único resultadocold: cada subscribe reejecuta; hot/share(): multicast

Dónde te topas Observables en Angular

// HttpClient SIEMPRE devuelve Observable
this.http.get<Product[]>('/api/catalog').subscribe(list => this.products = list);

Fugas de memoria (lo que preguntan)

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.

Qué decir (30 s)

"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."

2 · Los 4 operadores de aplanado

¿Qué es "aplanar"?

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?

OperadorCon el anterior…Caso real (POS / backoffice)
switchMaplo cancelabúsqueda de producto: solo importa la última tecla
mergeMaplo deja, todos en paraleloN validaciones independientes a la vez
concatMaphace cola, en ordenguardar renglones de un pedido sin que se pisen
exhaustMapignora los nuevos mientras trabajabotó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.

Qué decir

"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."

3 · Native Federation

El modelo mental

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.

Cómo funciona por dentro (esto lo dibujas)

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.

Qué te aportó

Qué te costó (los dolores — esto prueba que lo usaste de verdad)

Qué decir

"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."

4 · Otros temas (respuestas de bolsillo)

TemaQué decir
Signals vs RxJSSignals 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 detectionZone.js (default) vs zoneless (v18+). OnPush + inmutabilidad + pipe async. Con Signals, CD granular.
Standalone componentsDefault desde v17. Sin NgModule; dependencias en imports: [...] del componente.
Lazy loadingloadComponent / loadChildren en rutas. Bloques @defer para diferir render.
FormulariosReactive Forms tipados (v14+), validadores async, updateOn: 'blur'.
Interceptores HTTP funcionales(v15+) auth, retry, logging, y la idempotency-key de las integraciones.

Testing — tu ventaja diferencial (Karma/Jasmine + Jest)

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.

Plantilla de tu anécdota — complétala con datos reales

  • Componente: [nombre] — hace [qué].
  • Por qué lo testeaba: [regresión / feature nueva / bug].
  • Qué comprobaba exactamente: [render condicional / emisión de @Output / que llamó al servicio con los args correctos / rama de error].
  • Cómo (setup): TestBed con [imports standalone], mocks con [jasmine.createSpyObj / { provide: X, useValue }].
  • Async: [fakeAsync+tick() / HttpTestingController / whenStable].
  • Coverage al que apuntabas: [%] — mencionaste que en tu repo apuntan a cobertura alta.

Karma/Jasmine — patrones que debes poder escribir

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);
  }));
});

HttpTestingController

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();

Component harnesses de Angular Material (@angular/cdk/testing) — señal fuerte, ellos usan Material

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.

Jest — por qué tienen los dos y en qué difiere

Karma/JasmineJest
Entornonavegador real (Chrome headless)jsdom (simulado en Node)
Velocidadmás lento (arranca navegador)rápido, paralelo, watch mode ágil
Spies/mocksjasmine.createSpyjest.fn(), jest.mock() de módulos
Snapshotsno nativo
Setup en Angularviene por defectojest-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.

Testing en microfrontends

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.

Pruebas unitarias — paso a paso (para tontos)

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.

1 · Qué es un test unitario (y las 3 fases)

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):

  1. Arrange (preparar): monta el componente, prepara datos falsos.
  2. Act (actuar): dispara lo que pruebas (un clic, llamar un método).
  3. Assert (verificar): comprueba que el resultado es el esperado.

2 · Anatomía de un spec de Angular

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 */ });
});
PalabraQué 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".
beforeEachcódigo que corre antes de cada it, para empezar limpio.
TestBedel "banco de pruebas": un Angular en miniatura configurado solo con lo necesario.
fixtureenvuelve la instancia del componente y su DOM.
fixture.componentInstancela clase (propiedades y métodos).
fixture.nativeElementel 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.

3 · Mocks y spies (por qué y cómo)

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().

AssertionVerifica
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

4 · El test completo, explicado

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 tiempo

Có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().

5 · HttpTestingController — HTTP sin red

TestBed.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.

6 · Harnesses de Angular Material (señal fuerte — ellos usan Material 20.3)

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.

7 · Jest vs Karma — por qué tienen los dos

Karma / JasmineJest
Dónde correChrome headless — navegador realjsdom — navegador simulado en Node
Velocidadmás lento (arranca Chrome)rápido, paralelo, --watch ágil
Spies/mocksjasmine.createSpyjest.fn(), jest.mock() de módulos
Snapshotsno nativo
Setup en Angularpor defectojest-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."

Tu anécdota "el último test que escribí" (pregunta 3) — rellénala

  • Componente: [nombre] — hace [qué].
  • Por qué: regresión / feature nueva / subir coverage.
  • Qué comprobaba: render condicional / emisión de @Output / que llamó al servicio con los args correctos / rama de error.
  • Setup: TestBed con imports standalone, mock con jasmine.createSpyObj.
  • Async: 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."

8 · Testing en microfrontends

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.

App completa de punta a punta — la solución scratch (para tontos)

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 a la prueba con esto ya corriendo. Si te piden construir algo en vivo, partes de una base que compila y tiene tests verdes — no peleas con dotnet new ni con CORS mientras te miran.

0 · Qué vas a construir

  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).

1 · Prerrequisitos (verifica una vez)

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 -E -Q "SELECT name FROM sys.databases"   # ¿está PruebaTienda?

Si PruebaTienda no existe, corre el script de la pestaña SQL · paso a paso primero.

Extensiones de VS Code

ExtensiónPara 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-clientejecutar peticiones desde un archivo .http (sin Postman)
ms-mssql.mssqlcorrer SQL contra SQL Server desde el editor
editorconfig.editorconfigestilo de código consistente

2 · Estructura de carpetas

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

3 · Backend .NET paso a paso

3.1 · Crear el proyecto

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
dotnet add Prueba.Api.Tests package FluentAssertions

3.2 · Conectar la base con el back (connection string)

En backend/Prueba.Api/appsettings.Development.json:

{
  "ConnectionStrings": {
    "Sql": "Server=localhost;Database=PruebaTienda;Trusted_Connection=True;TrustServerCertificate=True"
  }
}

Trusted_Connection=True = login de Windows (sin usuario/clave). TrustServerCertificate=True evita el error de certificado en local.

3.3 · Program.cs (servicios + CORS + Swagger)

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

3.4 · Modelos (DTOs con validación)

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.

3.5 · Repositorio con Dapper (SQL escrito a mano)

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.

3.6 · Controller (los verbos HTTP con sus códigos correctos)

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ÉxitoNo existeBody inválido
GET lista200 + PagedResult
GET por id200 + producto404
POST201 + Location400 + errores
PUT204 (sin body)404400
DELETE204404

3.7 · Correr y probar el back solo

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

4 · Tests del backend (lo que pide la oferta)

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
Nota sobre la BD en los tests

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.

5 · Conectar el back con el front (proxy + CORS)

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).

6 · Frontend Angular + Material paso a paso

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.

6.1 · HttpClient

// frontend/src/app/app.config.ts
import { provideHttpClient } from '@angular/common/http';
// ...
providers: [provideHttpClient(), /* ...lo demás */]

6.2 · El servicio (un método por operación del back)

// 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}`); }
}

6.3 · La pantalla: formulario + tabla paginada + feedback

// 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.

6.4 · Ruta

// app.routes.ts
export const routes = [
  { path: '', loadComponent: () =>
      import('./products-page.component').then(m => m.ProductsPageComponent) },
];

7 · Tests del frontend

// 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.

8 · Shortcuts — no escribir todo a mano

8.1 · Scripts que arrancan todo junto

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

8.2 · Reset de la base con un comando

db/reset.ps1:

sqlcmd -S localhost -E -i db/schema.sql
sqlcmd -S localhost -E -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.

8.3 · VS Code — tasks (Ctrl+Shift+B)

// .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": [] }
  ]
}

8.4 · VS Code — debug (F5)

// .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+)"
      } }
  ]
}

8.5 · VS Code — settings + extensiones recomendadas

// .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"
] }

9 · Atajos de teclado que sí vas a usar

TeclaHace
F5Iniciar / depurar el API con breakpoints
Ctrl+Shift+BCorrer 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…)

10 · Checklist "listo para la prueba"

  • npm run dev levanta API (/swagger) y Angular (localhost:4200) sin errores.
  • ☐ La pantalla lista productos en la tabla paginada y el paginador cambia de página.
  • ☐ Crear, editar y eliminar desde la UI funcionan y sale el snackbar.
  • ☐ Un POST inválido (nombre vacío) muestra error, no rompe.
  • npm test → backend y frontend todo verde.
  • db/reset.ps1 recrea la BD si la ensucias durante la prueba.
  • ☐ Sabes explicar en 1 frase cada capa (abajo).

11 · Qué decir mientras lo enseñas

12 · Si te piden construir en vivo (orden, ~50 min)

  1. Abres la solución scratch (ya compila). ~2 min.
  2. Muestras el GET paginado en Swagger. ~3 min.
  3. Agregas el campo / endpoint que pidan: modelo → repo → controller. ~15 min.
  4. Agregas su validación y un test que la cubra. ~10 min.
  5. Lo enganchas en la pantalla Angular (columna en la tabla o campo en el form). ~15 min.
  6. npm test en vivo, verde. ~5 min.

La rebanada vertical — tutorial completo, paso por paso

De carpeta vacía a app corriendo con tests verdes. Sigue cada paso en orden; no asume nada. Al final, la sección 8 muestra cómo extender la rebanada en vivo — que es lo que te pedirán en la prueba.

Para los atajos (npm run dev, tasks y launch de VS Code) mira la pestaña App completa. Aquí vamos comando por comando y archivo por archivo para que entiendas cada pieza.

Lo que vas a tener al terminar

1 · Carpetas y proyectos (comandos de creación)

# 1.1 carpeta raíz
mkdir rebanada && cd rebanada

# 1.2 backend: solución + API + proyecto de tests
mkdir backend && cd backend
dotnet new sln   -n Rebanada
dotnet new webapi -n Rebanada.Api --use-controllers
dotnet new xunit  -n Rebanada.Api.Tests
dotnet sln add Rebanada.Api Rebanada.Api.Tests
dotnet add Rebanada.Api.Tests reference Rebanada.Api

# 1.3 paquetes del API
dotnet add Rebanada.Api package Dapper
dotnet add Rebanada.Api package Microsoft.Data.SqlClient
dotnet add Rebanada.Api package Swashbuckle.AspNetCore

# 1.4 paquetes de test
dotnet add Rebanada.Api.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add Rebanada.Api.Tests package FluentAssertions

cd ..    # de vuelta a rebanada/

backend/Rebanada.sln agrupa Rebanada.Api (la API) y Rebanada.Api.Tests (xUnit, que ya "ve" al API por el reference).

2 · La base de datos y la tabla

Abre SSMS o Azure Data Studio, conéctate a localhost, pega y ejecuta (F5):

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)
);
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);
GO

SELECT * FROM dbo.Product;   -- deben salir 5 filas

UNIQUE en Sku y CHECK en Stock: dos reglas que el API va a respetar; si intentas romperlas desde la API, SQL Server las rechaza.

3 · Backend — archivo por archivo

3.1 · Connection string

Reemplaza backend/Rebanada.Api/appsettings.Development.json:

{
  "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } },
  "ConnectionStrings": {
    "Sql": "Server=localhost;Database=PruebaApi;Trusted_Connection=True;TrustServerCertificate=True"
  }
}

3.2 · Program.cs (reemplázalo completo)

using System.Data;
using Microsoft.Data.SqlClient;
using Rebanada.Api;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddScoped<IDbConnection>(_ =>
    new SqlConnection(builder.Configuration.GetConnectionString("Sql")));
builder.Services.AddScoped<ProductRepository>();

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 { }   // <-- necesario para los tests

Registra Swagger; una SqlConnection nueva por request; el repositorio; y una política CORS que deja entrar a localhost:4200.

3.3 · Models.cs (archivo nuevo en Rebanada.Api/)

using System.ComponentModel.DataAnnotations;

namespace Rebanada.Api;

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);

ProductDto = lo que sale del API. ProductInput = lo que entra; sus atributos hacen el 400 automático. PagedResult = la lista + el total para el paginador.

3.4 · ProductRepository.cs (archivo nuevo)

using System.Data;
using Dapper;

namespace Rebanada.Api;

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 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 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 });
}

Todo el SQL a mano, con parámetros. GetPaged hace la página y el COUNT(*) en una sola ida con QueryMultiple. Update/Delete devuelven filas afectadas → 0 = no existe.

3.5 · Controllers/ProductsController.cs (archivo nuevo)

using Microsoft.AspNetCore.Mvc;

namespace Rebanada.Api.Controllers;

[ApiController]
[Route("api/products")]
public class ProductsController(ProductRepository repo) : ControllerBase
{
    [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 { } p ? Ok(p) : 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();
}

Borra los archivos de ejemplo WeatherForecast.cs y Controllers/WeatherForecastController.cs.

3.6 · Correr y ver Swagger

dotnet run --project backend/Rebanada.Api
# consola: "Now listening on: https://localhost:7xxx"
# abre https://localhost:7xxx/swagger

En Swagger: GET /api/products → "Try it out" → Execute → deben salir tus 5 productos dentro de items.

3.7 · Probar TODOS los verbos con un archivo .http

Crea backend/Rebanada.Api/Rebanada.Api.http (ajusta el puerto). Da clic en "Send Request" arriba de cada bloque:

@base = https://localhost:7xxx

### 1. lista paginada  -> 200
GET {{base}}/api/products?pageIndex=0&pageSize=3

### 2. uno por id  -> 200
GET {{base}}/api/products/1

### 3. id que no existe  -> 404
GET {{base}}/api/products/9999

### 4. crear válido  -> 201 + header Location
POST {{base}}/api/products
Content-Type: application/json

{ "sku": "SKU-900", "name": "Melatonina", "stock": 15 }

### 5. crear inválido  -> 400 (nombre vacío, stock negativo)
POST {{base}}/api/products
Content-Type: application/json

{ "sku": "x", "name": "", "stock": -2 }

### 6. actualizar  -> 204
PUT {{base}}/api/products/1
Content-Type: application/json

{ "sku": "SKU-001", "name": "Vitamina C 1000mg", "stock": 80 }

### 7. actualizar id inexistente  -> 404
PUT {{base}}/api/products/9999
Content-Type: application/json

{ "sku": "SKU-ZZZ", "name": "Nada", "stock": 1 }

### 8. borrar  -> 204
DELETE {{base}}/api/products/5

### 9. borrar id inexistente  -> 404
DELETE {{base}}/api/products/9999

Cada respuesta trae su código arriba a la derecha. Confirma que coinciden con los comentarios.

4 · Tests del backend

Borra backend/Rebanada.Api.Tests/UnitTest1.cs. Crea ProductsApiTests.cs:

using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Rebanada.Api;
using Xunit;

public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
    public ProductsApiTests(WebApplicationFactory<Program> factory) => _client = factory.CreateClient();

    [Fact]
    public async Task Get_lista_devuelve_200_y_respeta_pageSize()
    {
        var res = await _client.GetAsync("/api/products?pageIndex=0&pageSize=3");
        res.StatusCode.Should().Be(HttpStatusCode.OK);
        var body = await res.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
        body!.Items.Count.Should().BeLessThanOrEqualTo(3);
        body.Total.Should().BeGreaterThan(0);
    }

    [Fact]
    public async Task Get_por_id_inexistente_devuelve_404()
        => (await _client.GetAsync("/api/products/999999")).StatusCode
            .Should().Be(HttpStatusCode.NotFound);

    [Fact]
    public async Task Post_valido_devuelve_201_y_luego_se_puede_leer()
    {
        var input = new { sku = $"T{Guid.NewGuid():N}".Substring(0, 10), name = "Test", stock = 4 };
        var post = await _client.PostAsJsonAsync("/api/products", input);
        post.StatusCode.Should().Be(HttpStatusCode.Created);

        var created = await post.Content.ReadFromJsonAsync<ProductDto>();
        (await _client.GetAsync($"/api/products/{created!.ProductId}")).StatusCode
            .Should().Be(HttpStatusCode.OK);

        await _client.DeleteAsync($"/api/products/{created.ProductId}");   // limpieza
    }

    [Fact]
    public async Task Post_invalido_devuelve_400()
        => (await _client.PostAsJsonAsync("/api/products", new { sku = "x", name = "", stock = -1 }))
            .StatusCode.Should().Be(HttpStatusCode.BadRequest);

    [Fact]
    public async Task Put_inexistente_devuelve_404()
        => (await _client.PutAsJsonAsync("/api/products/999999",
                new { sku = "SKU-ABC", name = "X", stock = 1 }))
            .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
# Passed!  -  Failed: 0, Passed: 6
Qué prueba cada test
  • Get_lista: responde 200, respeta pageSize y el total es > 0.
  • Get_por_id_inexistente: 404 cuando el id no está.
  • Post_valido: 201 al crear y el recurso se puede volver a leer (POST + GET juntos).
  • Post_invalido: la validación de DataAnnotations corta con 400.
  • Put/Delete_inexistente: 404 cuando ExecuteAsync afecta 0 filas.

5 · Frontend — crear el proyecto

cd ..                 # a rebanada/  (si venías de backend/)
ng new frontend --style=scss --ssr=false
#  ? routing? .................  Yes
cd frontend
ng add @angular/material
#  ? theme ..................... Azure/Blue  (cualquiera)
#  ? typography styles? ........ Yes
#  ? browser animations? ....... Yes

ng add @angular/material instala la última versión de Material que corresponde a tu Angular y engancha el tema en styles.scss e index.html.

5.1 · Proxy: conectar el front con el back

Crea frontend/proxy.conf.json (mismo puerto que el API):

{
  "/api": {
    "target": "https://localhost:7xxx",
    "secure": false,
    "changeOrigin": true
  }
}

Cuando Angular pida /api/products, el dev-server lo reenvía a https://localhost:7xxx/api/products. Sin esto tendrías error de CORS y de URL.

5.2 · Registrar HttpClient — src/app/app.config.ts

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes), provideHttpClient()],
};

5.3 · El servicio — 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}`);
  }
  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}`); }
}

Un método por operación del back. Los interface son el contrato con la API — mismos nombres de campo que el JSON.

5.4 · El componente (lógica) — src/app/products-page.component.ts

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 done = (msg: string) => {
      this.snack.open(msg, 'OK', { duration: 2500 });
      this.editingId = null;
      this.form.reset({ sku: '', name: '', stock: 0 });
      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: () => done('Producto creado'), error: fail });
    } else {
      this.api.update(this.editingId, dto).subscribe({ next: () => done('Producto actualizado'), error: fail });
    }
  }

  edit(p: Product) {
    this.editingId = p.productId;
    this.form.setValue({ sku: p.sku, name: p.name, stock: p.stock });
  }

  cancelEdit() {
    this.editingId = null;
    this.form.reset({ sku: '', name: '', stock: 0 });
  }

  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', 'Cerrar', { duration: 3000 }),
    });
  }
}

5.5 · El componente (template) — products-page.component.html

<h1>Productos</h1>

<form [formGroup]="form" (ngSubmit)="submit()" class="form-row">
  <mat-form-field>
    <mat-label>SKU</mat-label>
    <input matInput formControlName="sku" placeholder="SKU-123">
    <mat-error>Entre 3 y 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 puede ser negativo</mat-error>
  </mat-form-field>

  <button mat-flat-button color="primary" type="submit">
    {{ editingId === null ? 'Crear' : 'Guardar' }}
  </button>
  <button mat-button type="button" *ngIf="editingId !== null" (click)="cancelEdit()">
    Cancelar
  </button>
</form>

<table mat-table [dataSource]="rows" class="mat-elevation-z1">
  <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>

5.6 · Estilos — products-page.component.scss

:host { display: block; max-width: 900px; margin: 24px auto; padding: 0 16px; }
.form-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: baseline; margin-bottom: 16px; }
table { width: 100%; }
mat-paginator { margin-top: 8px; }

5.7 · La ruta — src/app/app.routes.ts

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./products-page.component').then(m => m.ProductsPageComponent),
  },
];

Deja src/app/app.component.html con solo <router-outlet /> (borra el HTML de bienvenida).

5.8 · Correr y validar en el navegador

# terminal 1  (en backend/)
dotnet run --project Rebanada.Api

# terminal 2  (en frontend/)
ng serve --proxy-config proxy.conf.json --open

Se abre http://localhost:4200. Comprueba en este orden:

  1. La tabla muestra los productos y el paginador dice "1 – 5 of 5". Cambia el tamaño de página a 10.
  2. Crear: SKU SKU-777, Nombre Zinc, Stock 20 → "Crear" → aviso "Producto creado" y la fila nueva.
  3. Validación: borra el nombre y pon Stock -5 → los campos se marcan en rojo y no deja enviar.
  4. Editar: "Editar" en una fila → el formulario se llena, el botón dice "Guardar" → cambia el stock → "Guardar" → aviso "actualizado".
  5. Eliminar: "Eliminar" → confirmas → desaparece la fila.

6 · Tests del frontend

6.1 · Spec del servicio — src/app/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() arma la URL con pageIndex y pageSize', () => {
    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);
  });
});

6.2 · Spec del componente (opcional, suma) — products-page.component.spec.ts

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { ProductsPageComponent } from './products-page.component';

describe('ProductsPageComponent', () => {
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ProductsPageComponent],
      providers: [provideHttpClient(), provideHttpClientTesting(), provideNoopAnimations()],
    });
    http = TestBed.inject(HttpTestingController);
  });

  it('al iniciar pide la primera página', () => {
    const fixture = TestBed.createComponent(ProductsPageComponent);
    fixture.detectChanges();                       // dispara ngOnInit
    const req = http.expectOne('/api/products?pageIndex=0&pageSize=5');
    req.flush({ items: [{ productId: 1, sku: 'A', name: 'X', stock: 1 }],
               total: 1, pageIndex: 0, pageSize: 5 });
    expect(fixture.componentInstance.rows.length).toBe(1);
  });
});
ng test --watch=false
# TOTAL: 5 SUCCESS

7 · Recapitulación: las 3 costuras de la conexión

CosturaDónde viveQué la hace funcionar
Base ↔ Backappsettings.Development.json + Program.csconnection string + AddScoped<IDbConnection>; Dapper usa esa conexión
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 peticiones desde http://localhost:4200

8 · Extender la rebanada EN VIVO (el ejercicio real)

Con la base corriendo, te piden: "agrega un campo Price, que no pueda ser 0 o negativo". Los 8 micro-pasos, en orden:

#ArchivoCambio
1SQL (SSMS)ALTER TABLE dbo.Product ADD Price DECIMAL(10,2) NOT NULL CONSTRAINT DF_Price DEFAULT(0) CONSTRAINT CK_Price CHECK (Price > 0); — si ya hay filas, primero un UPDATE a un valor válido
2Models.csProductDto: agrega decimal Price. ProductInput: [Range(0.01, 1000000)] public decimal Price { get; set; }
3ProductRepository.csPrice en los SELECT, en el INSERT (..., Price) VALUES (..., @Price) y en el UPDATE ... Price = @Price
4ProductsController.csnada — recibe ProductInput y ya
5Swagger / .httpPOST con "price": 0400; con "price": 99.90 → 201
6product.service.tsprice: number en Product y ProductInput
7componente .ts + .htmlcols: mete 'price'; form: price: [0, [Validators.required, Validators.min(0.01)]]; en el HTML una columna price ({{ p.price | currency:'MXN' }}) y un mat-form-field de precio
8testsbackend: Post_precio_cero_devuelve_400; frontend: expect(req.request.body.price).toBe(50)
dotnet test && ng test --watch=false      # verde = terminaste

Mientras lo haces di: "voy de abajo hacia arriba: el invariante en la BD con un CHECK, luego el DTO para el 400 automático, el SQL del repo, pruebo en Swagger, y recién ahí subo al front con el mismo límite en el validador."

9 · Otros dos ejercicios (mismo método)

A · Endpoint de búsqueda por SKU (parcial)
  1. DB: nada.
  2. Repo: 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 + "%" });
  3. Controller: [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.
  4. Servicio: search(term: string) { return this.http.get<Product[]>(`${this.base}/search?sku=${encodeURIComponent(term)}`); }
  5. Componente: un <input matInput> con searchCtrl.valueChanges.pipe(debounceTime(300), switchMap(t => this.api.search(t))) → aquí luces RxJS.
  6. Test: el servicio arma /api/products/search?sku=vi; back: búsqueda sin match → 200 y [].
B · Borrado lógico (soft delete)
  1. DB: ALTER TABLE dbo.Product ADD IsActive BIT NOT NULL CONSTRAINT DF_IsActive DEFAULT(1);
  2. Repo: GetPaged y GetById agregan WHERE IsActive = 1. DeleteAsync pasa a UPDATE dbo.Product SET IsActive = 0 WHERE ProductId = @id AND IsActive = 1 (sigue devolviendo filas afectadas → 404 si ya estaba inactivo o no existe).
  3. Controller: sin cambios (el DELETE ahora "desactiva").
  4. Front: sin cambios visibles — los inactivos ya no llegan.
  5. Test: DELETE → 204; un segundo GET /{id} del mismo → 404; el GET lista ya no lo trae.
  6. Qué dices: "borrado lógico para no perder historial ni romper FKs; el GET filtra por IsActive y el DELETE queda idempotente."

10 · Checklist final

  • dotnet run → Swagger con 5 endpoints; los 9 requests del .http dan los códigos esperados.
  • dotnet test → 6 verdes.
  • ng serve --proxy-config proxy.conf.json → la pantalla lista, pagina, crea, edita, elimina y valida.
  • ng test --watch=false → verdes.
  • ☐ Puedes hacer el cambio de la sección 8 (campo con validación + su test) en < 20 min sin mirar.
  • ☐ Sabes recitar la tabla de la sección 7 (las 3 costuras).

Ejecución en vivo — el guion de la prueba técnica

Esta pestaña no explica (para eso está Rebanada vertical). Es el guion de ejecución: los scripts, comandos y atajos exactos, en orden, para levantar la rebanada y resolver un ejercicio en vivo sin pensar. Ensáyalo hasta que salga en < 20 min.

Declara tu atajo. Al empezar di: "tengo un script que levanta el esqueleto de la solución; de ahí en adelante todo es en vivo". Es legítimo llevar tu scaffold — lo que evalúan es cómo resuelves el requerimiento, no cuánto tardas en teclear dotnet new.

1 · Prepara tus atajos ANTES (una vez)

Ten a un paste de distancia (gist privado o gestor de snippets)

El resto de los archivos los escriben los scripts — no memorices código carácter por carácter, memoriza el orden y los comandos.

Atajos que sí vas a usar

HerramientaAtajoPara
.NETdotnet watch run --project Rebanada.Apihot reload: guardas y recompila, no reinicias
Angular CLIng g s product · ng g c products-pagegenera archivo + spec; luego pegas el cuerpo
VS CodeCtrl+.quick-fix: agrega using/import que falten
VS CodeF2renombrar símbolo en todo el proyecto
VS CodeCtrl+ñ (o `)terminal integrada
VS CodeF5correr/depurar el API con breakpoints
REST Client"Send Request" sobre cada bloque del .httpprobar la API sin Postman

2 · Scaffold del backend — un solo script

Guárdalo como scaffold-backend.ps1. Desde una carpeta vacía (p. ej. rebanada/): ./scaffold-backend.ps1. Crea la solución, los 3 proyectos, instala paquetes y escribe los 6 archivos del API.


# scaffold-backend.ps1  ·  levanta el esqueleto de la API en ~60s
# Uso: desde una carpeta vacia (p. ej. rebanada/)  ->  ./scaffold-backend.ps1
$ErrorActionPreference = 'Stop'
$PSDefaultParameterValues['Set-Content:Encoding'] = 'utf8'

New-Item -ItemType Directory -Force backend | Out-Null
Set-Location backend

dotnet new sln    -n Rebanada
dotnet new webapi -n Rebanada.Api --use-controllers
dotnet new xunit  -n Rebanada.Api.Tests
dotnet sln add Rebanada.Api Rebanada.Api.Tests
dotnet add Rebanada.Api.Tests reference Rebanada.Api
dotnet add Rebanada.Api package Dapper
dotnet add Rebanada.Api package Microsoft.Data.SqlClient
dotnet add Rebanada.Api package Swashbuckle.AspNetCore
dotnet add Rebanada.Api.Tests package Microsoft.AspNetCore.Mvc.Testing
dotnet add Rebanada.Api.Tests package FluentAssertions

Remove-Item Rebanada.Api/WeatherForecast.cs,
            Rebanada.Api/Controllers/WeatherForecastController.cs,
            Rebanada.Api.Tests/UnitTest1.cs -ErrorAction SilentlyContinue

Set-Content Rebanada.Api/appsettings.Development.json -Value @'
{
  "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } },
  "ConnectionStrings": {
    "Sql": "Server=localhost;Database=PruebaApi;Trusted_Connection=True;TrustServerCertificate=True"
  }
}
'@

Set-Content Rebanada.Api/Program.cs -Value @'
using System.Data;
using Microsoft.Data.SqlClient;
using Rebanada.Api;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddScoped<IDbConnection>(_ => new SqlConnection(
    builder.Configuration.GetConnectionString("Sql")));
builder.Services.AddScoped<ProductRepository>();
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 { }
'@

Set-Content Rebanada.Api/Models.cs -Value @'
using System.ComponentModel.DataAnnotations;
namespace Rebanada.Api;

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);
'@

Set-Content Rebanada.Api/ProductRepository.cs -Value @'
using System.Data;
using Dapper;
namespace Rebanada.Api;

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 g = await db.QueryMultipleAsync(sql, new { pageIndex, pageSize });
        var items = (await g.ReadAsync<ProductDto>()).AsList();
        var total = await g.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 });
}
'@

Set-Content Rebanada.Api/Controllers/ProductsController.cs -Value @'
using Microsoft.AspNetCore.Mvc;
namespace Rebanada.Api.Controllers;

[ApiController]
[Route("api/products")]
public class ProductsController(ProductRepository repo) : ControllerBase
{
    [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 { } p ? Ok(p) : 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();
}
'@

Set-Content Rebanada.Api.Tests/ProductsApiTests.cs -Value @'
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Rebanada.Api;
using Xunit;

public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _c;
    public ProductsApiTests(WebApplicationFactory<Program> f) => _c = f.CreateClient();

    [Fact] public async Task Get_lista_200()
    {
        var r = await _c.GetAsync("/api/products?pageIndex=0&pageSize=3");
        r.StatusCode.Should().Be(HttpStatusCode.OK);
        var b = await r.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
        b!.Items.Count.Should().BeLessThanOrEqualTo(3);
    }

    [Fact] public async Task Get_id_inexistente_404()
        => (await _c.GetAsync("/api/products/999999")).StatusCode
            .Should().Be(HttpStatusCode.NotFound);

    [Fact] public async Task Post_valido_201()
    {
        var input = new { sku = "T" + Guid.NewGuid().ToString("N").Substring(0, 8), name = "Test", stock = 4 };
        var post = await _c.PostAsJsonAsync("/api/products", input);
        post.StatusCode.Should().Be(HttpStatusCode.Created);
        var created = await post.Content.ReadFromJsonAsync<ProductDto>();
        await _c.DeleteAsync("/api/products/" + created!.ProductId);
    }

    [Fact] public async Task Post_invalido_400()
        => (await _c.PostAsJsonAsync("/api/products", new { sku = "x", name = "", stock = -1 }))
            .StatusCode.Should().Be(HttpStatusCode.BadRequest);

    [Fact] public async Task Put_inexistente_404()
        => (await _c.PutAsJsonAsync("/api/products/999999", new { sku = "SKU-ABC", name = "X", stock = 1 }))
            .StatusCode.Should().Be(HttpStatusCode.NotFound);

    [Fact] public async Task Delete_inexistente_404()
        => (await _c.DeleteAsync("/api/products/999999")).StatusCode
            .Should().Be(HttpStatusCode.NotFound);
}
'@

Write-Host ""
Write-Host "Backend listo." -ForegroundColor Green
Write-Host "Siguiente:  1) crea la BD PruebaApi (paso 2)   2) dotnet run --project backend/Rebanada.Api"

3 · La base de datos (pégala en SSMS)

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)
);
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);
GO

4 · Scaffold del frontend — un solo script

Primero, a mano (no se puede scriptar el prompt): ng new frontend --style=scss --ssr=false (routing: Yes) y luego cd frontend, ng add @angular/material (tema a elegir, typography: Yes, animations: Yes). Después guarda esto como scaffold-frontend.ps1 y córrelo desde frontend/ con el puerto real de tu API:

./scaffold-frontend.ps1 -Port 7123

# scaffold-frontend.ps1  ·  configura el Angular ya creado
# Uso: desde  rebanada/frontend/  (despues de  ng new frontend  +  ng add @angular/material)
#      ./scaffold-frontend.ps1 -Port 7123        <- pon el puerto real de tu API
param([string]$Port = "7xxx")
$ErrorActionPreference = 'Stop'
$PSDefaultParameterValues['Set-Content:Encoding'] = 'utf8'

Set-Content proxy.conf.json -Value @"
{
  "/api": { "target": "https://localhost:$Port", "secure": false, "changeOrigin": true }
}
"@

Set-Content src/app/app.config.ts -Value @'
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes), provideHttpClient()],
};
'@

Set-Content src/app/app.routes.ts -Value @'
import { Routes } from '@angular/router';
export const routes: Routes = [
  { path: '', loadComponent: () =>
      import('./products-page.component').then(m => m.ProductsPageComponent) },
];
'@

Set-Content src/app/app.component.html -Value '<router-outlet />'

Set-Content src/app/product.service.ts -Value @'
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}`); }
}
'@

Set-Content src/app/products-page.component.ts -Value @'
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',
})
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?.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 }),
    });
  }
}
'@

Set-Content src/app/products-page.component.html -Value @'
<h2>Productos</h2>

<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 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" [pageIndex]="pageIndex"
               [pageSizeOptions]="[5, 10, 25]" (page)="onPage($event)"></mat-paginator>
'@

Set-Content src/app/product.service.spec.ts -Value @'
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 con pageIndex y pageSize', () => {
    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 el 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);
  });
});
'@

Write-Host ""
Write-Host "Frontend configurado (puerto API: $Port)." -ForegroundColor Green
Write-Host "Siguiente:  ng serve --proxy-config proxy.conf.json --open"

5 · Secuencia cronometrada (base en ~18 min)

minAcciónComando / atajo
0:00Scaffold backendpega y corre scaffold-backend.ps1
0:02Crear la BDSSMS: pega el bloque de la sección 3
0:03Correr APIdotnet run --project backend/Rebanada.Api → abre /swagger, prueba el GET
0:05Tests backenddotnet test6 verdes
0:06Crear Angularng new frontend --style=scss --ssr=false (mientras compila, explicas el diseño)
0:09Añadir Materialcd frontend && ng add @angular/material
0:11Scaffold frontend./scaffold-frontend.ps1 -Port 7123 (puerto de tu API)
0:13Correr Angularng serve --proxy-config proxy.conf.json --open
0:15Validar en el navegadorlistar · paginar · crear · error de validación · editar · eliminar
0:17Tests frontendng test --watch=false → verde
0:18Base paradapides el requerimiento del ejercicio

6 · Ejercicios reales — playbooks de ejecución

Cada uno es una lista de teclazos con el código exacto. Practica hasta hacerlos en el tiempo indicado, narrando.

Ejercicio 1 · Campo Price con validación (~12 min)

  1. SSMS:
    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 viejas
  2. Models.csProductDto: agrega , decimal Price. ProductInput: agrega
    [Range(0.01, 1000000, ErrorMessage = "Precio > 0")] public decimal Price { get; set; }
  3. ProductRepository.cs → 3 sitios: los 2 SELECT (..., Stock, Price), el INSERT ((Sku, Name, Stock, Price) VALUES (@Sku, @Name, @Stock, @Price)) y el UPDATE (SET ..., Price=@Price).
  4. ProductsController.cs → nada.
  5. Verifica el back: en el .http, POST con "price": 0400; con "price": 149.9 → 201.
  6. product.service.tsProduct y ProductInput: agrega price: number;
  7. products-page.component.ts: cols mete 'price' antes de 'acciones'; en form agrega price: [0, [Validators.required, Validators.min(0.01)]],
  8. products-page.component.html: nueva columna y campo
    <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>
  9. Tests: en 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);
  10. 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."

Ejercicio 2 · Búsqueda por SKU con debounce (~15 min) — luce RxJS

  1. DB: nada (Sku ya es UNIQUE → indexado).
  2. ProductRepository.cs → método nuevo:
    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 + "%" });
  3. ProductsController.cs → acción nueva:
    [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".
  4. Verifica: GET /api/products/search?sku=SKU-00 → 200 con varios; ?sku=zzz → 200 y [].
  5. product.service.ts:
    search(term: string) {
      return this.http.get<Product[]>(`${this.base}/search?sku=${encodeURIComponent(term)}`);
    }
  6. products-page.component.ts — agrega el control y el flujo:
    // 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.)
  7. .html — arriba de la tabla:
    <mat-form-field>
      <mat-label>Buscar por SKU</mat-label>
      <input matInput [formControl]="searchCtrl">
    </mat-form-field>
  8. Test del servicio:
    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([]);
    });
  9. 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."

Ejercicio 3 · Borrado lógico (~12 min)

  1. SSMS: ALTER TABLE dbo.Product ADD IsActive BIT NOT NULL CONSTRAINT DF_IsActive DEFAULT (1);
  2. ProductRepository.cs:
    • 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 });
  3. Controller: nada (el DELETE ahora "desactiva").
  4. Verifica: DELETE /api/products/2 → 204; GET /api/products/2 → 404; la lista ya no lo trae; DELETE otra vez → 404 (idempotente, afecta 0 filas).
  5. Front: nada (los inactivos no llegan).
  6. Test backend — agrega:
    [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);
    }
  7. 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."

7 · Si truena en vivo (fix de 20 s)

SíntomaCausaFix
Navegador: CORS / blocked by CORS policyfalta CORS o el proxyapp.UseCors("dev") va antes de MapControllers(); y arranca Angular con --proxy-config proxy.conf.json
Primer GET → 500connection string / SQL apagado / BD no creadarevisa appsettings.Development.json, que el servicio SQL Server corra, que exista PruebaApi
dotnet test no compilafalta public partial class Program { } o el referenceagrégalos; dotnet add Rebanada.Api.Tests reference Rebanada.Api
Angular: NullInjectorError: HttpClientfalta provideHttpClient()mételo en app.config.ts
Material se ve sin estilosng add @angular/material no terminó bienrevisa que styles.scss tenga el bloque de tema que agrega el schematic
El proxy no conectapuerto de proxy.conf.json ≠ puerto del APIcopia el puerto de la consola de dotnet run ("Now listening on...")
Tests de integración: "content root" o 404 en todoWebApplicationFactory no halla el APIque el proyecto de tests tenga el reference al API y el paquete Mvc.Testing

8 · Plan de ensayo

  1. Corrida 1 — con Rebanada vertical, sin prisa. Entiende cada archivo.
  2. Corrida 2 — esta pestaña, con cronómetro, puedes mirar. Anota dónde te trabas.
  3. Corrida 3 — esta pestaña, cronómetro, sin mirar, grabándote la voz.
  4. Corrida 4 — scaffold + un ejercicio que no elijas tú (dado, moneda), < 35 min, narrando todo.

Meta: base < 20 min · ejercicio < 15 min · sin silencios largos.

Cloud + Integraciones — AWS Lambda → Azure

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.

Tabla de equivalencias

AWSAzure¿Equivale limpio?
LambdaAzure FunctionsParcial — Functions usa bindings declarativos (input/output por atributos); en Lambda escribes las llamadas al SDK a mano. Modelo isolated worker en .NET.
API GatewayAzure API Management / HTTP trigger + Front DoorNo — APIM es más pesado y caro; para casos simples basta el HTTP trigger de la Function.
Step FunctionsDurable Functions / Logic AppsNo — Step Functions es una máquina de estados en JSON; Durable Functions es orquestación en código C#. Mentalidad distinta.
DynamoDBCosmos DBNo — Cosmos es multi-modelo, otro modelo de particionado y de costo (Request Units).
S3Blob StorageSí, cercano.
SQS / SNSStorage Queues / Service Bus / Event GridNo 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.
CloudWatchApplication Insights / Azure MonitorApp Insights es más rico para .NET (tracing distribuido casi gratis).
IAM rolesManaged Identity + RBAC (+ a veces App Registrations en Entra ID)No — otro modelo mental de identidad y permisos.
SAM / CDK / Serverless FrameworkBicep / ARM / azd, y Azure DevOps Pipelines (ellos)No — IaC y CI distintos; ellos ya usan Azure DevOps.

La respuesta modelo (di esto)

"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 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.

Integraciones externas — patrones (esto es el corazón del puesto)

"Integrar nuevos proveedores externos tomando ownership de toda la capa." Ten estos patrones listos:

Amazon SP-API / Shopify / Stripe — lo mínimo a saber

Preguntas conductuales (STAR)

Formato STAR: Situación · Tarea · Acción (tú, en primera persona, concreto) · Resultado (con número si puedes). 60–90 s cada una. Abajo están alineadas a los valores de NEWCO y a lo que van a dudar de ti.

Faltan tus insumos. Para dejar los borradores completos necesito 3–4 logros concretos tuyos (proyecto · tu rol · impacto medible · tecnología). Mientras, cada historia abajo tiene un esqueleto con lo que ya se sabe de tu paso por SEUS y marcado [RELLENAR] donde va tu dato real.

1 · "Cuéntame de una vez que tomaste ownership de algo de punta a punta"

Por qué la hacen: el puesto es exactamente eso (modelo de datos → servicio → endpoint → componente).

S/T: En el POS/backoffice farmacéutico, el módulo de [recepción electrónica / compra directa / monitor de embarques — elige uno] necesitaba [RELLENAR: qué problema de negocio].

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. [RELLENAR: 1 decisión técnica difícil que tomaste].

R: Salió a producción y [RELLENAR: métrica — tiempo ahorrado, errores reducidos, volumen procesado]. Hoy es uno de los 18+ módulos vivos.

2 · "Un bug en producción: qué pasó y qué cambiaste para que no se repita"

Por qué: corren 18 módulos en producción; "experiencia de compra confiable" es un valor.

Esqueleto: [RELLENAR: el bug] → detección (¿monitoreo? ¿reporte de usuario?) → causa raíz → fix inmediato → y la parte que importa: el test de regresión que agregaste (Karma o Jest) + [cambio de proceso: validación, alerta, revisión].

3 · "Integración con una API externa que se portó mal"

Por qué: el core del puesto es integrar Stripe/Shopify/SP-API.

Si tienes la historia: [RELLENAR: qué proveedor/servicio, qué falló — timeouts, rate limit, datos inconsistentes] → cómo lo hiciste resiliente (reintentos, idempotencia, conciliación).
Si no la tienes: dilo y pivota — "no he integrado un proveedor de pagos todavía, pero así es como lo abordaría" y explicas outbox + idempotency + webhooks (pestaña Cloud). Es honesto y demuestra criterio.

4 · "Aprendiste algo nuevo bajo presión"

Native Federation es tu historia natural: [RELLENAR: por qué migraron a microfrontends, qué plazo tenías] → lo aprendiste, migraste [N] módulos, y los dolores que resolviste (version skew, comunicación entre MFEs). Resultado: deploy independiente por equipo.

5 · "Desacuerdo técnico con alguien del equipo"

Esqueleto: [RELLENAR: la decisión — p. ej. Karma vs Jest, estructura de un módulo, EF vs SQL a mano] → cómo expusiste tu posición con datos → qué acordaron → qué aprendiste de la otra postura. Clave: mostrar que cambias de opinión con evidencia y que priorizas el resultado del equipo.

6 · "Trabajar con producto / gente no técnica"

Por qué: el JD pide "traducir requerimientos de negocio a specs técnicas".

Esqueleto: [RELLENAR: un requerimiento ambiguo de negocio] → preguntas que hiciste para acotarlo → cómo lo convertiste en spec → cómo validaste que era lo que querían antes de construir.

Cómo encuadrar las dudas sobre tu historial (Pregunta 6)

Lo que dudanEncuadre (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 ser el mayor contribuidor del repo y a dueño de varios 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 2021Factual y hacia adelante: "[RELLENAR tu situación real: terminé el plan de estudios / me falta la titulación / estoy en proceso de X]. 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.
Adaptabilidad a un codebase nuevo"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). Un codebase nuevo es la misma habilidad."

Preguntas conductuales cortas — ten una línea para cada una

Guion de la entrevista hablada

"Cuéntame de ti" — pitch de 60–90 s

"Soy desarrollador full-stack con 5 años en [SEUS], trabajando en un POS y backoffice para retail farmacéutico — hoy 18+ módulos en producción, cosas como compra directa, recepción electrónica y monitor de embarques.

Del lado del frontend es donde tengo más profundidad: Angular —ahora en 20—, RxJS, Angular Material, y migramos a microfrontends con Native Federation, donde fui el mayor contribuidor del repositorio 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, donde pueda tener ownership de features completas — y su stack es casi el mismo en el que ya me muevo, así que puedo contribuir rápido y a la vez profundizar más en la parte de .NET y datos."

Notas de entrega: di "5 años" (es lo que ya tienen en tu CV). Menciona SQL Server tú mismo, sin esperar a que lo saquen — le quita dramatismo a la pregunta 1. No escondas el inglés básico ni el AWS.

"¿Por qué NEWCO / por qué este puesto?"

Respuestas preparadas a preguntas incómodas

Tu nivel de inglés (te lo van a preguntar directo)

"Mi inglés es [básico / básico-intermedio — sé honesto] de lectura. Leo documentación técnica en inglés —de hecho la de Angular ya la consumo así— y me apoyo en traducción cuando el texto es denso. No lo hablo con fluidez todavía; lo estoy trabajando activamente [con práctica diaria — Anki, una app]. Para comunicación escrita asíncrona me defiendo; para una reunión en vivo en inglés todavía no estoy listo. Sé que SP-API, Stripe y Angular están documentados en inglés y con eso puedo."

No exageres — lo van a testear en la misma llamada. No te disculpes tres veces. Una respuesta, clara, con el plan de mejora.

Expectativa de salario

Ver pestaña Negociación. Resumen: pregunta su rango primero; si insisten, da un rango cuyo piso sea tu objetivo real, no tu mínimo.

Disponibilidad / cuándo puedes empezar

"Disponible de inmediato" ya lo tienen. Si sigues en SEUS, aclara tu tiempo de aviso real.

"No veo SQL en tu CV"

"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."

Preguntas que TÚ haces (elige 4–6 según con quién hables)

Al tech lead

Al hiring manager / producto

Esa última pregunta resuelve el hueco del toolchain — si no te lo han dicho, te lo dicen ahí.

Negociación

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.

Tu marco (de tu perfil)

Estimación de mercado (remoto MX, full-stack Angular + .NET, ~5 años)

EscenarioRango mensual aprox. (MXN)
Nómina local, empresa no-tech pequeña40,000 – 65,000
Honorarios / contractor, pago influido por USD55,000 – 90,000+
Tu objetivo declaradohasta 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.

Guion

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?"

Qué preguntar antes de aceptar

Qué NO decir

Plan de repaso — 5 días

Si tienes menos días, colapsa 1–2 y 3, pero el Día 1 (SQL) y el Día 5 (entorno + ensayo) son intocables.

Las pestañas SQL · paso a paso, Angular · paso a paso y Testing · paso a paso tienen la versión explicada desde cero de los Días 1–3. App completa (prueba) arma la solución scratch de punta a punta (SQL → .NET → Angular + Material) con sus tests y shortcuts de VS Code; Rebanada vertical es el tutorial completo del ejercicio, paso a paso; Ejecución en vivo es el guion cronometrado con scripts para hacerlo en la prueba real.

Día 1 — SQL Server (la pregunta que decide)

Día 2 — Angular a fondo

Día 3 — Testing + .NET/API

Día 4 — Cloud + Integraciones + Inglés

Día 5 — Ensayo general + ENTORNO

Checklist de entorno (haz un dry-run completo, de punta a punta)

  • SQL Server (Developer / Express / LocalDB / Docker) + SSMS o Azure Data Studio. Conéctate y corre un SELECT.
  • Base de práctica sembrada con las 4 tablas — para tener contra qué correr el JOIN si te lo piden en vivo.
  • .NET SDK (8 o 9): dotnet --info funciona.
  • Solución scratch pre-creada: Web API vacía + consola, con Dapper y Microsoft.Data.SqlClient ya referenciados. No quieres pelear con dotnet new durante la prueba.
  • Node LTS + Angular CLI versión ~20: ng version funciona. Un proyecto Angular que compile y corra ng test verde.
  • Editor: VS Code + VS (o el que uses), con extensiones C# y Angular.
  • Git configurado.
  • Screen share probado en la plataforma que usarán (Meet/Zoom/Teams). Cámara, micrófono, segundo monitor si aplica.
  • ☐ Lugar sin ruido, buena luz, plan B de internet (hotspot).
  • ☐ Manda un correo 1–2 días antes: "para la prueba técnica tengo listo SQL Server, .NET SDK X y Angular CLI Y — ¿algo más que deba preparar?"

Cheat-sheet — leer 10 min antes

Pitch en 3 bullets

  • 5 años full-stack · POS + backoffice retail farmacéutico · 18+ módulos en producción · mayor contribuidor del repo (86 commits).
  • Angular 20 senior: RxJS, Material, microfrontends con Native Federation. Testeo con Karma/Jasmine + Jest.
  • Backend .NET + REST + SQL Server con acceso a datos a mano. Nube: AWS Lambda. Busco full-stack real, remoto.

Por qué NEWCO

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.

SQL — reflejos (dilos mientras escribes)

  • Fecha: >= @From AND < @ToExclusive — NUNCA BETWEEN. Nada de funciones sobre la columna.
  • Paginación: ORDER BY determinista + columna única de desempate, luego OFFSET/FETCH. Keyset para páginas profundas.
  • Parámetros siempre. Índice en (OrderDate DESC, OrderId DESC).
  • Transacción: SET XACT_ABORT ON + TRY/CATCH + IF XACT_STATE() <> 0 ROLLBACK + THROW.
  • ACID · aislamiento READ COMMITTED default · lost update → UPDATE en una sentencia o UPDLOCK · deadlock → orden consistente + retry 1205 · concurrencia optimista con rowversion.
  • Nada de HTTP dentro de la transacción → outbox + idempotency key.
  • A mano = Dapper: conn.QueryAsync<T>(sql, params), transacción con BeginTransactionAsync y pasar tx.

Observable vs Promise

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).

Native Federation en una línea

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.

AWS → Azure: lo que NO equivale

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.

Inglés — una respuesta, sin disculparse de más

"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."

Historial laboral

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.

Número

Pregunta su rango + esquema (nómina/honorarios) + moneda PRIMERO. Si insisten: 65,000–85,000 MXN. Nunca menciones 35k.

Mis 3 preguntas top

  • ¿Cómo escriben el acceso a datos en .NET — Dapper, ADO, EF? ¿Monolito modular o microservicios?
  • ¿Cuántos microfrontends hay y cómo comparten estado? ¿Qué servicios de Azure usan realmente?
  • PrestaShop + Shopify + Amazon: ¿cuál es la fuente de verdad de catálogo/inventario/pedidos? ¿Hay migración en curso?

Antes de colgar / antes de la prueba

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.