test(balance): cross-cutting integration tests (#144) #152
1 changed files with 574 additions and 0 deletions
574
src/__integration__/balance-flow.test.ts
Normal file
574
src/__integration__/balance-flow.test.ts
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
/**
|
||||
* Integration tests for the Bilan (balance sheet) feature — Issue #144.
|
||||
*
|
||||
* Cross-cutting tests that exercise the *whole* TS surface in one go:
|
||||
*
|
||||
* account → priced category → priced snapshot → linked transfer → return
|
||||
*
|
||||
* Like `category-migration.test.ts` we cannot spin up real `tauri-plugin-sql`
|
||||
* (the bridge only lives inside the Tauri WebView). Instead we drive every
|
||||
* service against an in-memory FakeDb that:
|
||||
* - records every executed SQL,
|
||||
* - returns hand-tuned `select` results to mimic the real schema,
|
||||
* - simulates `lastInsertId` / `rowsAffected` for INSERT/DELETE.
|
||||
*
|
||||
* The Tauri `invoke` is mocked — `computeAccountReturn` lives on the Rust
|
||||
* side (`compute_account_return`), so we assert the request payload and
|
||||
* have the mock return a stable `AccountReturn` shape. The Rust math itself
|
||||
* is covered by `return_calculator.rs`'s `#[cfg(test)] mod tests`.
|
||||
*
|
||||
* Scope (from spec-plan-bilan.md, Issue #144):
|
||||
* 1. End-to-end happy path
|
||||
* 2. Currency-lock (CHECK `currency = 'CAD'`) at the service level
|
||||
* 3. Migration v9 on a seeded DB — covered in Rust (lib.rs `mod tests`)
|
||||
* 4. TransactionsPage non-regression for the inlined transfer icon
|
||||
* 5. Coverage best-effort (deferred — see decisions-log.md)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../services/db", () => ({
|
||||
getDb: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../services/profileService", () => ({
|
||||
loadProfiles: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getDb } from "../services/db";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { loadProfiles } from "../services/profileService";
|
||||
|
||||
import {
|
||||
createBalanceCategory,
|
||||
createBalanceAccount,
|
||||
listBalanceAccounts,
|
||||
createSnapshot,
|
||||
upsertSnapshotLines,
|
||||
listLinesBySnapshot,
|
||||
linkTransfer,
|
||||
unlinkTransfer,
|
||||
listAccountTransfers,
|
||||
computeAccountReturn,
|
||||
BalanceServiceError,
|
||||
PRICED_VALUE_TOLERANCE,
|
||||
} from "../services/balance.service";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FakeDb harness: scripted select results, recorded execute calls.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeDb {
|
||||
calls: Array<{ sql: string; params?: unknown[] }>;
|
||||
selectQueue: Array<unknown[]>;
|
||||
executeQueue: Array<{ lastInsertId?: number; rowsAffected?: number }>;
|
||||
select: ReturnType<typeof vi.fn>;
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeFakeDb(): FakeDb {
|
||||
const db: FakeDb = {
|
||||
calls: [],
|
||||
selectQueue: [],
|
||||
executeQueue: [],
|
||||
select: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
};
|
||||
db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
|
||||
db.calls.push({ sql, params });
|
||||
if (db.selectQueue.length === 0) {
|
||||
throw new Error(`Unscripted SELECT (no queued result): ${sql}`);
|
||||
}
|
||||
return db.selectQueue.shift();
|
||||
});
|
||||
db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
|
||||
db.calls.push({ sql, params });
|
||||
if (db.executeQueue.length === 0) {
|
||||
// Default: 1 affected row, monotonically increasing lastInsertId
|
||||
return { rowsAffected: 1, lastInsertId: db.calls.length };
|
||||
}
|
||||
return db.executeQueue.shift();
|
||||
});
|
||||
return db;
|
||||
}
|
||||
|
||||
let fake: FakeDb;
|
||||
|
||||
beforeEach(() => {
|
||||
fake = makeFakeDb();
|
||||
vi.mocked(getDb).mockResolvedValue(
|
||||
{ select: fake.select, execute: fake.execute } as never
|
||||
);
|
||||
vi.mocked(invoke).mockReset();
|
||||
vi.mocked(loadProfiles).mockReset();
|
||||
});
|
||||
|
||||
// Helper: queue a sequence of SELECT results in FIFO order.
|
||||
function queueSelects(...rows: unknown[][]) {
|
||||
for (const r of rows) fake.selectQueue.push(r);
|
||||
}
|
||||
|
||||
// Helper: queue a sequence of EXECUTE results in FIFO order.
|
||||
function queueExecutes(
|
||||
...results: Array<{ lastInsertId?: number; rowsAffected?: number }>
|
||||
) {
|
||||
for (const r of results) fake.executeQueue.push(r);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. End-to-end happy path
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Walks the full Bilan flow as if the user just installed the app:
|
||||
// 1. Create a custom priced category ("etf-prov")
|
||||
// 2. Create an account on that category with a stock symbol
|
||||
// 3. Reload the joined accounts list and confirm the account is there
|
||||
// 4. Create a snapshot dated today
|
||||
// 5. Save a priced line for the new account (qty * price = value)
|
||||
// 6. Read the lines back and confirm what was persisted
|
||||
// 7. Link a transaction to the account as a +CAD deposit
|
||||
// 8. Compute the account's return → mock returns a stable shape, we
|
||||
// assert the wiring uses the active profile's db_filename and forwards
|
||||
// every parameter as ISO YYYY-MM-DD.
|
||||
//
|
||||
// Each step is asserted at the service-call level (params + queued SQL),
|
||||
// then we run cross-step sanity checks.
|
||||
|
||||
describe("integration — Bilan end-to-end happy path", () => {
|
||||
it("walks account → priced category → snapshot → transfer → return cleanly", async () => {
|
||||
// ---- 1. Create a custom priced category ----
|
||||
queueExecutes({ lastInsertId: 100 });
|
||||
const categoryId = await createBalanceCategory({
|
||||
key: "etf-prov",
|
||||
i18n_key: "balance.category.etf_prov",
|
||||
kind: "priced",
|
||||
sort_order: 80,
|
||||
});
|
||||
expect(categoryId).toBe(100);
|
||||
|
||||
// ---- 2. Create the account on that category ----
|
||||
// Service first SELECTs the category to validate it exists, then
|
||||
// INSERTs the account.
|
||||
queueSelects([
|
||||
{
|
||||
id: 100,
|
||||
key: "etf-prov",
|
||||
i18n_key: "balance.category.etf_prov",
|
||||
kind: "priced",
|
||||
sort_order: 80,
|
||||
is_active: 1,
|
||||
is_seed: 0,
|
||||
},
|
||||
]);
|
||||
queueExecutes({ lastInsertId: 7 });
|
||||
const accountId = await createBalanceAccount({
|
||||
balance_category_id: categoryId,
|
||||
name: "VFV (Wealthsimple)",
|
||||
symbol: "VFV.TO",
|
||||
});
|
||||
expect(accountId).toBe(7);
|
||||
|
||||
// ---- 3. listBalanceAccounts: account joined with category ----
|
||||
queueSelects([
|
||||
{
|
||||
id: 7,
|
||||
balance_category_id: 100,
|
||||
name: "VFV (Wealthsimple)",
|
||||
symbol: "VFV.TO",
|
||||
currency: "CAD",
|
||||
notes: null,
|
||||
is_active: 1,
|
||||
archived_at: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
category_key: "etf-prov",
|
||||
category_i18n_key: "balance.category.etf_prov",
|
||||
category_kind: "priced",
|
||||
},
|
||||
]);
|
||||
const accounts = await listBalanceAccounts();
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0].category_kind).toBe("priced");
|
||||
expect(accounts[0].symbol).toBe("VFV.TO");
|
||||
|
||||
// ---- 4. Create a snapshot dated 2026-04-25 ----
|
||||
// createSnapshot first SELECTs by date (must be empty) then INSERTs.
|
||||
queueSelects([]); // no existing snapshot
|
||||
queueExecutes({ lastInsertId: 50 });
|
||||
const snapshotId = await createSnapshot({ snapshot_date: "2026-04-25" });
|
||||
expect(snapshotId).toBe(50);
|
||||
|
||||
// ---- 5. Save a priced line: 10 shares × $200 = $2000 ----
|
||||
// upsertSnapshotLines: SELECT snapshot, then DELETE existing lines, then
|
||||
// one INSERT per line, then UPDATE updated_at.
|
||||
queueSelects([
|
||||
{
|
||||
id: 50,
|
||||
snapshot_date: "2026-04-25",
|
||||
notes: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
]);
|
||||
queueExecutes(
|
||||
{ rowsAffected: 0 }, // delete (no prior lines)
|
||||
{ lastInsertId: 200 }, // insert priced line
|
||||
{ rowsAffected: 1 } // bump updated_at
|
||||
);
|
||||
await upsertSnapshotLines(50, [
|
||||
{
|
||||
account_id: 7,
|
||||
account_kind: "priced",
|
||||
quantity: 10,
|
||||
unit_price: 200,
|
||||
value: 2000,
|
||||
},
|
||||
]);
|
||||
|
||||
// The 2nd execute call should be the INSERT with the priced placeholders.
|
||||
const insertCall = fake.calls.find(
|
||||
(c) =>
|
||||
typeof c.sql === "string" &&
|
||||
c.sql.includes("INSERT INTO balance_snapshot_lines")
|
||||
);
|
||||
expect(insertCall).toBeDefined();
|
||||
expect(insertCall!.params).toEqual([50, 7, 10, 200, 2000]);
|
||||
|
||||
// ---- 6. Read the lines back ----
|
||||
queueSelects([
|
||||
{
|
||||
id: 200,
|
||||
snapshot_id: 50,
|
||||
account_id: 7,
|
||||
quantity: 10,
|
||||
unit_price: 200,
|
||||
value: 2000,
|
||||
price_source: "manual",
|
||||
price_fetched_at: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
]);
|
||||
const lines = await listLinesBySnapshot(50);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0].value).toBe(2000);
|
||||
expect(lines[0].quantity).toBe(10);
|
||||
expect(lines[0].unit_price).toBe(200);
|
||||
|
||||
// ---- 7. Link a transaction (id=42) as a +CAD deposit (in) ----
|
||||
// linkTransfer: SELECT existing duplicate (none), then INSERT.
|
||||
queueSelects([]); // no existing duplicate
|
||||
queueExecutes({ lastInsertId: 9 });
|
||||
const transferId = await linkTransfer(7, 42, "in", "monthly contribution");
|
||||
expect(transferId).toBe(9);
|
||||
const linkCall = fake.calls.find(
|
||||
(c) =>
|
||||
typeof c.sql === "string" &&
|
||||
c.sql.includes("INSERT INTO balance_account_transfers")
|
||||
);
|
||||
expect(linkCall).toBeDefined();
|
||||
expect(linkCall!.params).toEqual([7, 42, "in", "monthly contribution"]);
|
||||
|
||||
// ---- 8. Compute the account return ----
|
||||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||||
active_profile_id: "max",
|
||||
profiles: [
|
||||
{
|
||||
id: "max",
|
||||
name: "Max",
|
||||
color: "#3b82f6",
|
||||
pin_hash: null,
|
||||
db_filename: "max.db",
|
||||
created_at: "0",
|
||||
},
|
||||
],
|
||||
});
|
||||
const fakeReturn = {
|
||||
value_start: 1500,
|
||||
value_end: 2000,
|
||||
net_contributions: 400,
|
||||
return_pct: 0.0667, // (2000 - 1500 - 400) / (1500 + W*400) ≈ 6.67%
|
||||
annualized_pct: 0.28,
|
||||
is_partial: false,
|
||||
has_no_transfers_warning: false,
|
||||
};
|
||||
vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);
|
||||
|
||||
const ret = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
|
||||
expect(ret).toEqual(fakeReturn);
|
||||
|
||||
// Wiring check: profile resolution + ISO date forwarding.
|
||||
expect(invoke).toHaveBeenCalledWith("compute_account_return", {
|
||||
dbFilename: "max.db",
|
||||
accountId: 7,
|
||||
periodStart: "2026-01-01",
|
||||
periodEnd: "2026-04-25",
|
||||
});
|
||||
|
||||
// ---- Cross-step sanity: every coherent value matches expectations.
|
||||
// The end snapshot value (2000) matches what we saved.
|
||||
expect(ret.value_end).toBe(2000);
|
||||
// The reported return is a finite, non-zero number on a non-trivial period.
|
||||
expect(ret.return_pct).not.toBeNull();
|
||||
expect(Number.isFinite(ret.return_pct!)).toBe(true);
|
||||
// Net contributions match the 1 linked transfer (+400 in).
|
||||
expect(ret.net_contributions).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("supports unlink as the inverse of link", async () => {
|
||||
queueExecutes({ rowsAffected: 1 });
|
||||
await expect(unlinkTransfer(7, 42)).resolves.toBeUndefined();
|
||||
const unlinkCall = fake.calls.find(
|
||||
(c) =>
|
||||
typeof c.sql === "string" &&
|
||||
c.sql.includes("DELETE FROM balance_account_transfers")
|
||||
);
|
||||
expect(unlinkCall!.params).toEqual([7, 42]);
|
||||
});
|
||||
|
||||
it("listAccountTransfers reads back what link wrote (joined view)", async () => {
|
||||
queueSelects([
|
||||
{
|
||||
id: 9,
|
||||
account_id: 7,
|
||||
transaction_id: 42,
|
||||
direction: "in",
|
||||
notes: "monthly contribution",
|
||||
created_at: "2026-04-25 10:00:00",
|
||||
transaction_date: "2026-04-15",
|
||||
transaction_description: "Wealthsimple contrib",
|
||||
transaction_amount: -400,
|
||||
account_name: "VFV (Wealthsimple)",
|
||||
},
|
||||
]);
|
||||
const links = await listAccountTransfers(7);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].direction).toBe("in");
|
||||
expect(links[0].account_name).toBe("VFV (Wealthsimple)");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Currency lock — CAD only at the MVP
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The MVP locks accounts to CAD: the SQL CHECK is `currency = 'CAD'` and the
|
||||
// service rejects any other value with a typed `currency_unsupported` before
|
||||
// the SQL even fires. Asserts:
|
||||
// - USD is rejected with the typed code,
|
||||
// - the rejection happens BEFORE any SELECT/EXECUTE on the DB,
|
||||
// - the default (no `currency` field) flows through and lands as 'CAD',
|
||||
// - the SQL CHECK side is covered in Rust (lib.rs `migration_v9_*` tests).
|
||||
|
||||
describe("integration — currency lock (CAD only)", () => {
|
||||
it("rejects USD at the service level with a typed error", async () => {
|
||||
await expect(
|
||||
createBalanceAccount({
|
||||
balance_category_id: 1,
|
||||
name: "USD account",
|
||||
currency: "USD",
|
||||
})
|
||||
).rejects.toBeInstanceOf(BalanceServiceError);
|
||||
|
||||
try {
|
||||
await createBalanceAccount({
|
||||
balance_category_id: 1,
|
||||
name: "USD account",
|
||||
currency: "USD",
|
||||
});
|
||||
} catch (e) {
|
||||
expect((e as BalanceServiceError).code).toBe("currency_unsupported");
|
||||
}
|
||||
// CRITICAL: the rejection must happen up-front — no DB hit.
|
||||
expect(fake.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts the default and persists 'CAD' explicitly", async () => {
|
||||
queueSelects([
|
||||
{
|
||||
id: 1,
|
||||
key: "cash",
|
||||
i18n_key: "balance.category.cash",
|
||||
kind: "simple",
|
||||
sort_order: 10,
|
||||
is_active: 1,
|
||||
is_seed: 1,
|
||||
},
|
||||
]);
|
||||
queueExecutes({ lastInsertId: 5 });
|
||||
await createBalanceAccount({
|
||||
balance_category_id: 1,
|
||||
name: "Encaisse",
|
||||
});
|
||||
const insertCall = fake.calls.find(
|
||||
(c) =>
|
||||
typeof c.sql === "string" &&
|
||||
c.sql.includes("INSERT INTO balance_accounts")
|
||||
);
|
||||
expect(insertCall).toBeDefined();
|
||||
// [category_id, name, symbol, currency, notes]
|
||||
expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null]);
|
||||
});
|
||||
|
||||
it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => {
|
||||
for (const ccy of ["EUR", "GBP", "JPY", "AUD"]) {
|
||||
await expect(
|
||||
createBalanceAccount({
|
||||
balance_category_id: 1,
|
||||
name: `Mystery ${ccy}`,
|
||||
currency: ccy,
|
||||
})
|
||||
).rejects.toMatchObject({ code: "currency_unsupported" });
|
||||
}
|
||||
expect(fake.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Priced-kind invariant — coherence of the qty × price = value chain
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Tied to the priced-kind path, but at the integration layer: a snapshot
|
||||
// saved with a drifting (qty * price ≠ value) line must be rejected before
|
||||
// any DB mutation, so the SQL CHECK never has the chance to fire and we
|
||||
// don't accidentally clear pre-existing lines.
|
||||
|
||||
describe("integration — priced invariant rejects out-of-tolerance saves", () => {
|
||||
it("does not run DELETE when one line is bad", async () => {
|
||||
queueSelects([
|
||||
{
|
||||
id: 50,
|
||||
snapshot_date: "2026-04-25",
|
||||
notes: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
upsertSnapshotLines(50, [
|
||||
{ account_id: 1, value: 1000 },
|
||||
{
|
||||
account_id: 7,
|
||||
account_kind: "priced",
|
||||
quantity: 10,
|
||||
unit_price: 25,
|
||||
// expected ≈ 250, way beyond ε
|
||||
value: 999,
|
||||
},
|
||||
])
|
||||
).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" });
|
||||
|
||||
// Critical safety: the DELETE must not have fired — otherwise the user
|
||||
// would lose all existing lines on a partial save.
|
||||
const deletes = fake.calls.filter(
|
||||
(c) =>
|
||||
typeof c.sql === "string" &&
|
||||
c.sql.includes("DELETE FROM balance_snapshot_lines")
|
||||
);
|
||||
expect(deletes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts a drift just within the tolerance", async () => {
|
||||
queueSelects([
|
||||
{
|
||||
id: 50,
|
||||
snapshot_date: "2026-04-25",
|
||||
notes: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
]);
|
||||
queueExecutes(
|
||||
{ rowsAffected: 0 },
|
||||
{ lastInsertId: 1 },
|
||||
{ rowsAffected: 1 }
|
||||
);
|
||||
// 12.34 * 1.07 = 13.2038... — drift well within ε = 0.01
|
||||
const drift = PRICED_VALUE_TOLERANCE * 0.5;
|
||||
await expect(
|
||||
upsertSnapshotLines(50, [
|
||||
{
|
||||
account_id: 7,
|
||||
account_kind: "priced",
|
||||
quantity: 10,
|
||||
unit_price: 10,
|
||||
value: 100 + drift,
|
||||
},
|
||||
])
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Returns: malformed period dates rejected before the Tauri invoke
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("integration — computeAccountReturn validates dates client-side", () => {
|
||||
it("rejects non-ISO dates without invoking the Rust command", async () => {
|
||||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||||
active_profile_id: "max",
|
||||
profiles: [
|
||||
{
|
||||
id: "max",
|
||||
name: "Max",
|
||||
color: "#000",
|
||||
pin_hash: null,
|
||||
db_filename: "max.db",
|
||||
created_at: "0",
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
computeAccountReturn(7, "01/01/2026", "2026-04-25")
|
||||
).rejects.toBeInstanceOf(BalanceServiceError);
|
||||
// The Tauri side must NOT have been hit — fail-fast on bad dates.
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects when the active profile cannot be resolved", async () => {
|
||||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||||
active_profile_id: "ghost",
|
||||
profiles: [],
|
||||
});
|
||||
await expect(
|
||||
computeAccountReturn(7, "2026-01-01", "2026-04-25")
|
||||
).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards a partial-period AccountReturn shape unchanged", async () => {
|
||||
// When `is_partial = true` (no V_start), the Rust side returns a payload
|
||||
// with explicit nulls. The TS shim must not coerce them away.
|
||||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||||
active_profile_id: "max",
|
||||
profiles: [
|
||||
{
|
||||
id: "max",
|
||||
name: "Max",
|
||||
color: "#000",
|
||||
pin_hash: null,
|
||||
db_filename: "max.db",
|
||||
created_at: "0",
|
||||
},
|
||||
],
|
||||
});
|
||||
const partial = {
|
||||
value_start: null,
|
||||
value_end: 1500,
|
||||
net_contributions: 200,
|
||||
return_pct: null,
|
||||
annualized_pct: null,
|
||||
is_partial: true,
|
||||
has_no_transfers_warning: false,
|
||||
};
|
||||
vi.mocked(invoke).mockResolvedValueOnce(partial);
|
||||
const out = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
|
||||
expect(out).toEqual(partial);
|
||||
expect(out.is_partial).toBe(true);
|
||||
expect(out.value_start).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue