Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-starter-templates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- tina-docs
- tinasaurus
- basic
node-version: [20, 22, 24]
node-version: [22, 24, 25]
outputs:
failed: ${{ steps.report-errors.outputs.failed }}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: Gamma Unrelated
---

<!-- TEST FIXTURE — do not delete. Pre-seeded static data for playwright filter/sort/pagination tests.
Filename: playwright-filter-aaa.md (sorts FIRST by filepath)
Title: "Gamma Unrelated" (sorts LAST by title index)
This divergence is intentional — it proves sort:"title" uses the real LevelDB index, not filepath order.
See tests/api/sorting.spec.ts and tests/api/filtering.spec.ts -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: Filter Alpha Post
---

<!-- TEST FIXTURE — do not delete. Pre-seeded static data for playwright filter/sort/pagination tests. See tests/api/filtering.spec.ts -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: Filter Beta Post
---

<!-- TEST FIXTURE — do not delete. Pre-seeded static data for playwright filter/sort/pagination tests. See tests/api/filtering.spec.ts -->
2 changes: 1 addition & 1 deletion playwright/tina-playwright/tests/api/document-crud.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ test.describe("Document CRUD lifecycle (md format)", () => {
expect(readAfterUpdateBody.data.post.title).toBe(
"Playwright CRUD Test (updated)"
);
expect(readAfterUpdateBody.data.post.body).toBe("Updated body content.");
expect(readAfterUpdateBody.data.post.body.trim()).toBe("Updated body content.");

// ------------------------------------------------------------------
// DELETE — contentCleanup.track handles deletion in teardown,
Expand Down
40 changes: 40 additions & 0 deletions playwright/tina-playwright/tests/api/filtering.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Filtering smoke test — verifies that the `post` schema's `title` field is
* correctly wired to GraphQL filtering end-to-end (schema → LevelDB → response).
* Uses pre-seeded static fixtures in content/post/ (playwright-filter-*.md).
*/

import { test, expect } from "../../fixtures/api-context";

const POST_CONNECTION_FILTER = `
query PostConnectionFilter($filter: PostFilter) {
postConnection(filter: $filter) {
totalCount
edges {
node {
title
id
}
}
}
}
`;

test("postConnection filter — title eq returns only the matching post", async ({
apiContext,
}) => {
const resp = await apiContext.post("/graphql", {
data: {
query: POST_CONNECTION_FILTER,
variables: { filter: { title: { eq: "Filter Alpha Post" } } },
},
});

expect(resp.ok()).toBeTruthy();
const body = await resp.json();
expect(body.errors).toBeUndefined();

const { totalCount, edges } = body.data.postConnection;
expect(totalCount).toBe(1);
expect(edges[0].node.title).toBe("Filter Alpha Post");
});
145 changes: 145 additions & 0 deletions playwright/tina-playwright/tests/api/pagination.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Pagination smoke tests — verifies that `first` and `after` cursor arguments
* work correctly against real LevelDB data.
*
* Uses the same pre-seeded static fixtures as filtering and sorting:
* content/post/playwright-filter-alpha.md → "Filter Alpha Post"
* content/post/playwright-filter-beta.md → "Filter Beta Post"
* content/post/playwright-filter-aaa.md → "Gamma Unrelated"
*
* Tests:
* 1. `first` limits the number of results returned
* 2. `hasNextPage` is true when more results exist, false on the last page
* 3. `after` cursor advances to the next page with no overlap
*/

import { test, expect } from "../../fixtures/api-context";

const POST_CONNECTION_PAGINATE = `
query PostConnectionPaginate($first: Float, $after: String, $sort: String) {
postConnection(first: $first, after: $after, sort: $sort) {
pageInfo {
hasNextPage
endCursor
}
edges {
cursor
node {
title
}
}
}
}
`;

// ── 1. `first` limits results ─────────────────────────────────────────────────

test("pagination — `first` limits the number of results returned", async ({
apiContext,
}) => {
const resp = await apiContext.post("/graphql", {
data: {
query: POST_CONNECTION_PAGINATE,
variables: { sort: "title", first: 2 },
},
});

expect(resp.ok()).toBeTruthy();
const body = await resp.json();
expect(body.errors).toBeUndefined();

// `first` is a limit — must return at most the requested number
expect(body.data.postConnection.edges.length).toBeLessThanOrEqual(2);
});

// ── 2. `hasNextPage` reflects whether more results exist ──────────────────────

test("pagination — hasNextPage is true on first page, false on last page", async ({
apiContext,
}) => {
// Page 1: fetch 2 posts sorted by title
const page1Resp = await apiContext.post("/graphql", {
data: {
query: POST_CONNECTION_PAGINATE,
variables: { sort: "title", first: 2 },
},
});

expect(page1Resp.ok()).toBeTruthy();
const page1Body = await page1Resp.json();
expect(page1Body.errors).toBeUndefined();

const cursor = page1Body.data.postConnection.pageInfo.endCursor;

// 3 seed posts exist, fetching 2 means more remain
expect(page1Body.data.postConnection.pageInfo.hasNextPage).toBe(true);

// Page 2: advance past page 1
const page2Resp = await apiContext.post("/graphql", {
data: {
query: POST_CONNECTION_PAGINATE,
variables: { sort: "title", first: 2, after: cursor },
},
});

expect(page2Resp.ok()).toBeTruthy();
const page2Body = await page2Resp.json();
expect(page2Body.errors).toBeUndefined();

// Page 2 must have results — if cursor was ignored this would be a repeat of page 1
expect(page2Body.data.postConnection.edges.length).toBeGreaterThan(0);

// After exhausting all posts, hasNextPage must be false
expect(page2Body.data.postConnection.pageInfo.hasNextPage).toBe(false);
});

// ── 3. `after` cursor advances pages with no overlap ──────────────────────────

test("pagination — `after` cursor returns the next page with no overlapping results", async ({
apiContext,
}) => {
// Page 1
const page1Resp = await apiContext.post("/graphql", {
data: {
query: POST_CONNECTION_PAGINATE,
variables: { sort: "title", first: 2 },
},
});

expect(page1Resp.ok()).toBeTruthy();
const page1Body = await page1Resp.json();
expect(page1Body.errors).toBeUndefined();

const page1Titles: string[] = page1Body.data.postConnection.edges.map(
({ node }: { node: { title: string } }) => node.title
);
const cursor = page1Body.data.postConnection.pageInfo.endCursor;

// Only meaningful if page 1 returned results and there is a next page
expect(page1Titles.length).toBeGreaterThan(0);
expect(page1Body.data.postConnection.pageInfo.hasNextPage).toBe(true);

// Page 2
const page2Resp = await apiContext.post("/graphql", {
data: {
query: POST_CONNECTION_PAGINATE,
variables: { sort: "title", first: 2, after: cursor },
},
});

expect(page2Resp.ok()).toBeTruthy();
const page2Body = await page2Resp.json();
expect(page2Body.errors).toBeUndefined();

const page2Titles: string[] = page2Body.data.postConnection.edges.map(
({ node }: { node: { title: string } }) => node.title
);

// Page 2 must have results — if empty, the cursor didn't advance or data is missing
expect(page2Titles.length).toBeGreaterThan(0);

// No title from page 1 should appear on page 2
for (const title of page2Titles) {
expect(page1Titles).not.toContain(title);
}
});
60 changes: 60 additions & 0 deletions playwright/tina-playwright/tests/api/search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Search index smoke tests — verifies that the `/searchIndex` route is
* built from real content and returns correct results.
*
* Uses the same pre-seeded static fixtures as filtering and sorting:
* content/post/playwright-filter-alpha.md → title: "Filter Alpha Post"
* content/post/playwright-filter-beta.md → title: "Filter Beta Post"
* content/post/playwright-filter-aaa.md → title: "Gamma Unrelated"
*
* Search terms are chosen so they appear only in the title, not the filename.
* This ensures we are testing that the title field is actually indexed, not
* just that the file path matches.
*
* "gamma" → only in title "Gamma Unrelated", not in filename "playwright-filter-aaa.md"
* "filter" → only in titles "Filter Alpha Post" / "Filter Beta Post", not in filename
* (filenames contain "filter" too — so this tests the opposite: precision)
*
* Tests:
* 1. Querying "gamma" returns the gamma post — proves title field is indexed
* 2. Querying "gamma" does not return the alpha or beta posts — proves precision
*/

import { test, expect } from "../../fixtures/api-context";

// ── 1. Title field is indexed — "gamma" only exists in the title ──────────────

test("searchIndex — querying a word from the title returns the matching post", async ({
apiContext,
}) => {
const resp = await apiContext.get(
`/searchIndex?q=${encodeURIComponent(JSON.stringify({ AND: ["gamma"] }))}`
);

expect(resp.ok()).toBeTruthy();
const body = await resp.json();

// If title is not indexed (searchable: true missing), gamma is not in the
// file path either, so RESULT_LENGTH would be 0
expect(body.RESULT_LENGTH).toBeGreaterThan(0);

const ids: string[] = body.RESULT.map((r: { _id: string }) => r._id);
expect(ids.some((id) => id.includes("playwright-filter-aaa"))).toBe(true);
});

// ── 2. Query is precise — "gamma" does not return unrelated posts ─────────────

test("searchIndex — querying 'gamma' does not return alpha or beta posts", async ({
apiContext,
}) => {
const resp = await apiContext.get(
`/searchIndex?q=${encodeURIComponent(JSON.stringify({ AND: ["gamma"] }))}`
);

expect(resp.ok()).toBeTruthy();
const body = await resp.json();

const ids: string[] = body.RESULT.map((r: { _id: string }) => r._id);
expect(ids.some((id) => id.includes("playwright-filter-alpha"))).toBe(false);
expect(ids.some((id) => id.includes("playwright-filter-beta"))).toBe(false);
});
Loading
Loading