Full CRUD Verification — End-to-End Data Flow from Angular to SQL Server

End-to-end verification of a full-stack CRUD feature means tracing the complete data flow — not just “does it work” but “does each layer behave correctly under all conditions.” A systematic verification covers the happy path, validation failures, authorization failures, concurrency conflicts, and the soft-delete cascade. Integration tests using WebApplicationFactory automate this verification so it runs on every pull request.

End-to-End Data Flow Trace

// ── WebApplicationFactory integration test ────────────────────────────────
public class PostsCrudTests : IClassFixture<BlogAppFactory>
{
    private readonly HttpClient _client;
    private readonly BlogAppFactory _factory;

    public PostsCrudTests(BlogAppFactory factory)
    {
        _factory = factory;
        _client  = factory.CreateClient();
    }

    [Fact]
    public async Task FullCrudLifecycle_HappyPath()
    {
        // 1. LOGIN — get JWT
        var loginRes = await _client.PostAsJsonAsync("/api/auth/login",
            new { email = "admin@blogapp.com", password = "Admin!123" });
        loginRes.EnsureSuccessStatusCode();
        var auth = await loginRes.Content.ReadFromJsonAsync<AuthResponse>();
        _client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", auth!.AccessToken);

        // 2. CREATE — POST /api/posts → 201 Created
        var createRes = await _client.PostAsJsonAsync("/api/posts", new {
            title   = "Integration Test Post",
            slug    = "integration-test-post",
            body    = "This is the test body content with enough words to pass validation.",
            status  = "draft",
        });
        Assert.Equal(HttpStatusCode.Created, createRes.StatusCode);
        var created = await createRes.Content.ReadFromJsonAsync<PostDto>();
        Assert.NotNull(created);
        Assert.NotEmpty(createRes.Headers.GetValues("Location").First());

        // 3. READ — GET /api/posts/{slug} — not visible yet (draft)
        var readPublic = await _client.GetAsync($"/api/posts/{created!.Slug}");
        Assert.Equal(HttpStatusCode.NotFound, readPublic.StatusCode);

        // 4. UPDATE — PUT with ETag
        var etag = Convert.ToBase64String(created.RowVersion);
        _client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue($"\"{etag}\""));
        var updateRes = await _client.PutAsJsonAsync($"/api/posts/{created.Id}", new {
            title = "Updated Integration Test Post",
            status = "published",
        });
        Assert.Equal(HttpStatusCode.OK, updateRes.StatusCode);
        _client.DefaultRequestHeaders.IfMatch.Clear();

        // 5. PUBLISH — post now visible publicly
        var readPublicAfter = await _client.GetAsync($"/api/posts/{created.Slug}");
        Assert.Equal(HttpStatusCode.OK, readPublicAfter.StatusCode);

        // 6. CONCURRENCY — stale ETag → 412
        _client.DefaultRequestHeaders.IfMatch.Add(new EntityTagHeaderValue($"\"{etag}\""));
        var conflictRes = await _client.PutAsJsonAsync($"/api/posts/{created.Id}", new {
            title = "Conflict attempt"
        });
        Assert.Equal(HttpStatusCode.PreconditionFailed, conflictRes.StatusCode);
        _client.DefaultRequestHeaders.IfMatch.Clear();

        // 7. DELETE — soft delete → 204
        var deleteRes = await _client.DeleteAsync($"/api/posts/{created.Id}");
        Assert.Equal(HttpStatusCode.NoContent, deleteRes.StatusCode);

        // 8. VERIFY — post is archived, not physically deleted
        using var scope = _factory.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var post = await db.Posts.IgnoreQueryFilters().FirstOrDefaultAsync(p => p.Id == created.Id);
        Assert.NotNull(post);
        Assert.Equal("archived", post!.Status);
        Assert.False(post.IsPublished);
    }
}
Note: WebApplicationFactory spins up the entire ASP.NET Core application in memory — including the real DI container, middleware pipeline, EF Core, and a test database. Integration tests written this way test the complete vertical slice (routing → controller → service → EF Core → database) with a single test method. Use a separate test database (SQLite in-memory or LocalDB with a unique name per test run) to avoid corrupting the development database during test runs.
Tip: Structure integration tests as a lifecycle test (one test per feature that runs create → read → update → delete in sequence) rather than isolated unit tests for each HTTP endpoint. This approach tests the interactions between operations (the created resource’s ID is used in subsequent operations) and reflects how the feature is actually used. Isolated endpoint tests are better suited for boundary conditions (invalid input, auth failures) that don’t require the full CRUD sequence.
Warning: Integration tests that share database state across test classes can produce flaky results due to test ordering dependencies. Each test class should set up its own data and clean up after itself (or use a fresh database per test run). Use IAsyncLifetime to initialize and teardown test data, or configure the test database to reset between test class runs. The WebApplicationFactory can be configured with a Scope that wipes the database before each test.

CRUD Data Flow Summary

Operation Angular API EF Core SQL Response
Create POST form data Validate → service.Create INSERT INTO Posts 201 + Location
Read List GET with query params service.GetPublished SELECT with OFFSET/FETCH 200 + PagedResult
Read Detail GET by slug service.GetBySlug SELECT with JOINs 200 + PostDto
Update PUT + If-Match ETag Check owner → service.Update UPDATE WHERE RowVersion=@etag 200 or 412
Delete DELETE by id Check owner → service.SoftDelete UPDATE Status=’archived’ 204

Common Mistakes

Mistake 1 — Integration tests sharing database state (flaky tests)

❌ Wrong — test A creates data that test B depends on; tests pass when run together but fail in isolation.

✅ Correct — each test class creates its own data; uses a fresh or reset test database per test run.

Mistake 2 — Skipping soft-delete verification (physically deleting instead of archiving)

❌ Wrong — integration test only checks 204 response; soft-delete trigger disabled; rows are physically deleted without noticing.

✅ Correct — step 8: query the database with IgnoreQueryFilters() and verify the row still exists with Status = 'archived'.

🧠 Test Yourself

The integration test deletes a post and then queries db.Posts.IgnoreQueryFilters().FirstOrDefaultAsync(p => p.Id == id). The global query filter is HasQueryFilter(p => !p.IsDeleted). Why is IgnoreQueryFilters() needed?