0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ATTダイアログが審査環境でだけ出ない件と、SQLiteの追記型マイグレーション/時刻注入テスト — Expo + React Native で iOS アプリを出した実装メモ

0
Posted at

個人開発で iOS アプリを1本リリースしました。App Store 審査は 3回(却下2回 → 承認)。落ちた原因はどちらもコードの質とは無関係な「知っているかどうか」の問題で、知らないと丸2日溶けます。

この記事は、その2件の審査リジェクトと、実装中に地味に効いた設計判断2つを、そのまま流用できる形で残すためのものです。ポエムは最後に少しだけ置きました。

リリース直後のため、ダウンロード数・ユーザー数・収益について書けることは何もありません。この記事は 実装と審査の通し方だけ の話です。

TL;DR

  • ATTのダイアログは UIApplicationStateactive のときしか出ない。 出せなかったときAPIはエラーを返さず、undetermined(未回答)をそのまま返す。ここを「ユーザーが拒否した」と誤読すると審査で 2.1 を食らう
  • SQLiteのマイグレーションは追加のみ(CREATE TABLE / ALTER TABLE ADD COLUMNPRAGMA user_version の更新をスキーマ適用と同一トランザクションに入れる。連番検証とロールバックのテストを書く
  • 時間依存ロジックからは Date.now() を全部追い出し、now: number / today: 'YYYY-MM-DD' を引数で受ける。 jest.useFakeTimers() は一度も要らなくなる
  • 自動更新サブスクを出すなら、アプリ説明文(App Description)にEULAのリンクを直接書く。 アプリ内のペイウォールにリンクがあるだけでは 3.1.2 で落ちる

動作環境

項目 バージョン・構成
Expo SDK 54
React Native 0.81
言語 TypeScript(strictany 禁止)
ローカルDB expo-sqlite(スキーマ v1 → v9)
広告 react-native-google-mobile-ads(AdMob)
ATT expo-tracking-transparency
課金 RevenueCat
計測 PostHog
ビルド EAS Build(開発ビルド / production)
テスト Jest(366件 / 25スイート)
対象OS iOS(iPhone専用・タブレット非対応)

ネイティブモジュールを含むため、Expo Go では動きません。EAS の開発ビルドが前提です。ATT・広告・課金まわりを触ったら、そのたびに開発ビルドの作り直しが要ります。

作ったもの(1段落だけ)

QuesToDo — やることを「クエスト」として登録し、完了するとEXPが入ってドット絵の冒険者がレベルアップするToDoアプリ。iPhone専用・日英対応・データは全部端末内(サーバーなし)。スキル52種、アバターパーツのカタログ84項目(うち3つは「装備しない」の選択肢なので、実際の絵は81点)、実績称号16種。ドット絵PNGは108枚(パーツ81 + 発動エフェクト27=9系統×3フレーム)で、全部 pngjs で書いた生成スクリプトの出力です。手描きは1枚もありません。

実装コードは 14,451行App.tsx + src 配下 86ファイル、テスト除外・空行込み)。開発期間は 2026-07-16 〜 2026-08-07 の23日で、相棒はAIエージェント(Claude Code)、設計判断と実機検証は自分がやりました。


① ATTダイアログが審査環境でだけ出ない(Guideline 2.1)

一番ハマったのがこれです。受け取った指摘はこの趣旨でした。

Guideline 2.1 — We were unable to locate the App Tracking Transparency permission request when reviewed on iOS.

手元の iPhone では出ていました。出ていなかったのは審査環境だけです。

真因

ATTのダイアログは、アプリが UIApplicationState.active のときにしか表示されません。 そして提示が見送られたとき、APIはエラーを返しません。ダイアログを出さないまま undetermined(未回答)を返して終わります。

自分のコードは、

  1. 起動直後(スプラッシュ解除中=まだ active でない)に要求し、
  2. 返ってきた undetermined を「granted ではない = 拒否された」と解釈して、
  3. プロセス内で二度と要求しない

という三段構えで、見事に一度きりのチャンスを溶かしていました。手元では起動が速くたまたま active に間に合っていた、それだけの差です。

さらに厄介なことに、Apple Developer Forums や各所の報告では active でない状態で一度呼ぶと、以後 active で呼び直してもポップアップが出なくなる」 ケースが挙がっています。つまり リトライ以前に「inactive のときは絶対に呼ばない」ことが本質です。

手元で再現させる手順

審査環境そのものは再現できませんが、同じ症状は手元で作れます

1. ATTの状態を「未回答」に戻す

設定一般転送またはiPhoneをリセットリセット位置情報とプライバシーをリセット

アプリの削除・再インストールだけでは戻らないことがあります。これが一番確実です(端末の他の権限も一緒にリセットされる点に注意)。

2. 前提条件を先に潰す

ここが崩れていると、コードをいくら直しても出ません。

  • 設定プライバシーとセキュリティトラッキング「Appからのトラッキング要求を許可」がONであること。OFFだとダイアログは出ず denied が返る
  • Info.plistNSUserTrackingUsageDescription があること
  • 他の権限ダイアログ(通知・位置情報など)を同時に出していないこと。別の権限要求が未応答のままだとATTは提示されない

Expo(CNG)なら app.json はこうなります。

app.json
{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSUserTrackingUsageDescription": "広告の最適化のために使用されます。拒否しても全機能を利用できます。"
      }
    },
    "plugins": [
      "expo-tracking-transparency",
      [
        "react-native-google-mobile-ads",
        {
          "iosAppId": "ca-app-pub-XXXXXXXXXXXXXXXX~XXXXXXXXXX"
        }
      ]
    ]
  }
}

3. 呼ぶ直前と直後をログに出す

import { AppState } from 'react-native';
import { requestTrackingPermissionsAsync } from 'expo-tracking-transparency';

console.log('[ATT] before', AppState.currentState);
const { status } = await requestTrackingPermissionsAsync();
console.log('[ATT] after', status, AppState.currentState);

4. 起動直後(最初のレンダー前)にこれを呼ぶ

ログがこうなり、ダイアログが一度も出なければ再現成功です。

[ATT] before inactive
[ATT] after  undetermined inactive

beforeactive なのに出ない場合は、原因はタイミングではなく前提条件(手順2)か、既に回答済み(granted / denied が返る)です。

修正(3点セット)

(1) active になるまで待ってから要求する

src/services/ads.tsx
/** activeを待つ上限。購読を張る直前にactiveへ遷移した取りこぼしへの保険 */
const ACTIVE_WAIT_TIMEOUT_MS = 10_000;

function waitUntilActive(): Promise<void> {
  if (AppState.currentState === 'active') {
    return Promise.resolve();
  }
  return new Promise((resolve) => {
    let settled = false;
    const finish = () => {
      if (settled) return;
      settled = true;
      subscription.remove();
      clearTimeout(timer);
      resolve();
    };
    const subscription = AppState.addEventListener('change', (state) => {
      if (state === 'active') finish();
    });
    const timer = setTimeout(finish, ACTIVE_WAIT_TIMEOUT_MS);
    // 購読を張るまでの間にactiveへ遷移していた場合の取りこぼしを拾う
    if (AppState.currentState === 'active') finish();
  });
}

ポイントは2つ。

  • タイムアウトを必ず置く。 active に永遠にならない環境で Promise が pending のまま残ると、後続の広告初期化ごと止まります
  • 購読を張った直後にもう一度 currentState を見る。 addEventListener を呼ぶ前の一瞬に active へ遷移していると、change イベントは二度と来ません

(2) undetermined は「拒否」ではなく「提示失敗」として扱い、間隔を空けて再試行する

src/services/ads.tsx
/**
 * ATTダイアログの提示を試みる間隔(ミリ秒)。要素数=最大試行回数。
 * iOSはUIのトランジション中(起動直後のスプラッシュ解除・モーダルの提示/解除
 * アニメーション)だと提示を見送ることがある。その場合エラーにはならず
 * undetermined のまま返るため、提示できるまで間隔を空けて再試行する。
 */
const ATT_ATTEMPT_DELAYS_MS = [600, 1_500, 3_000, 5_000, 8_000] as const;

export function ensureTrackingConsent(): Promise<boolean> {
  if (trackingConsent === null) {
    trackingConsent = (async (): Promise<boolean> => {
      // ATT非対応環境(iOS 13以下・Android等)はライブラリがgranted相当を返す
      if (!isTrackingApiAvailable()) return true;

      const current = await getTrackingStatus();
      if (current !== 'undetermined') {
        return current === 'granted'; // 回答済みなら終わり
      }

      for (const settleMs of ATT_ATTEMPT_DELAYS_MS) {
        await waitUntilActive();
        await delay(settleMs);
        // 待機中にinactiveへ落ちていたら要求しない(出ないまま消費されるため)
        if (AppState.currentState !== 'active') continue;

        const { status } = await requestTrackingPermissionsAsync();
        const normalized = toTrackingStatus(status);
        if (normalized !== 'undetermined') {
          return normalized === 'granted'; // 出た(許可でも拒否でも決着)
        }
        // 提示が見送られた(トランジション中など)。間隔を空けて再試行する
      }
      return false;
    })();
  }
  return trackingConsent;
}

await waitUntilActive()後にもう一度 AppState.currentState !== 'active' を確認しているのが肝です。delay() している最中にモーダルが開いたり通知ダイアログが出たりすると inactive に落ち、そこで要求すると「出ないまま1回消費」になります。

ステータスの正規化はこれだけ。restricteddenied に畳んで、呼び出し側の分岐を3値に固定します。

src/services/ads.tsx
export type TrackingStatus = 'undetermined' | 'granted' | 'denied';

function toTrackingStatus(status: string): TrackingStatus {
  if (status === 'granted') return 'granted';
  return status === 'undetermined' ? 'undetermined' : 'denied';
}

(3) 設定画面に手動導線を置く

自動要求がどうしてもすべる環境はあります。設定画面に「広告のトラッキング設定」の行を足し、そこから明示的に要求できるようにしました。審査担当者が自分で辿り着ける経路を用意しておく、という意味でも効きます。

src/services/ads.tsx
/**
 * ATTダイアログを即座に要求する(設定画面からの明示操作用)。
 * 回答済みの端末ではiOSがダイアログを出さないため、呼び出し側で状態を見て
 * 「設定アプリを開く」導線に切り替える
 */
export async function requestTrackingNow(): Promise<TrackingStatus> {
  if (!isTrackingApiAvailable()) return 'granted';
  const { status } = await requestTrackingPermissionsAsync();
  const normalized = toTrackingStatus(status);
  nonPersonalizedOnly = normalized !== 'granted';
  return normalized;
}

回答済み(granted / denied)の端末ではiOSがダイアログを出しません。ここで「ボタンを押したのに何も起きない」を作らないよう、回答済みなら Linking.openSettings() に切り替えるのを忘れずに。

ATTを待つと広告が止まる問題

素直に書くと「ATTの解決を待つ → 広告SDK初期化 → ロード」になりますが、ATTのリトライは最大で十数秒かかります。その間バナーが真っ白になるのは避けたいので、ATTの完了は待たずに初期化を進め、未許可の間は非パーソナライズ広告だけ要求する構成にしました。

src/services/ads.tsx
/** ATT拒否時(未回答含む)はtrue = 非パーソナライズ広告のみ要求する */
let nonPersonalizedOnly = true;

export function initializeAds(): Promise<void> {
  if (initialization === null) {
    ensureTrackingConsent().catch(() => {}); // 完了は待たない
    initialization = mobileAds().initialize().then(() => undefined);
  }
  return initialization;
}

react-native-google-mobile-adsrequestOptionsrequestNonPersonalizedAdsOnly など)は広告インスタンスの生成時に固定されます。ATTの結果が後から確定しても、既存インスタンスには反映されません。リワード広告のように使い捨てるものは、ロードのたびに新しいインスタンスを作る必要があります。

審査への出し方

  • 審査ノートに操作手順(起動 → ダイアログ表示、および設定画面からの再要求手順)を書く
  • 実機の画面収録(起動 → ATTダイアログ表示)を添付する

これで3回目に通りました。

AppState.currentState === 'active' を確認してから呼ぶ。undetermined はリトライ対象。この2つだけ覚えて帰ってください。expo-tracking-transparency でも生の ATTrackingManager でも、提示側の条件は同じです。
なお「リトライすべき」とAppleが明文で書いているわけではありません。これは自分の環境でこう直したら通った、という体験ベースの話です。


② SQLiteの追記型マイグレーションで v1 → v9 を事故ゼロで回す

ローカルDBはサーバーと違って壊したら復旧できません。ユーザーの端末にしかデータがないからです。決めたルールは2つだけ。

  • マイグレーションは追加のみCREATE TABLE / ALTER TABLE ADD COLUMN)。DROP・型変更・カラム削除は禁止
  • PRAGMA user_version で管理し、スキーマ適用とバージョン更新を同一トランザクションに入れる

ランナー

これで全部です。

src/db/migrations/index.ts
export interface Migration {
  /** 適用後の user_version(1始まりの連番) */
  version: number;
  up(db: SqlDatabase): void;
}

export const migrations: readonly Migration[] = [
  { version: 1, up: (db) => db.execBatch(SCHEMA_V1) },
  { version: 2, up: (db) => db.execBatch(SCHEMA_V2) },
  // …v9まで末尾に追記していく
];

export const LATEST_VERSION = migrations[migrations.length - 1].version;

/**
 * 現在の user_version から list の最新まで順番に適用する。
 * (listを引数に取るのは、旧バージョンフィクスチャからの昇格テストのため)
 */
export function runMigrations(db: SqlDatabase, list: readonly Migration[]): void {
  if (list.length === 0) return;
  const latest = list[list.length - 1].version;
  let current = db.getUserVersion();

  // アプリより新しいDB = ダウングレード。触らずに落とす(データ保護)
  if (current > latest) throw new SchemaTooNewError(current, latest);

  for (const m of list) {
    if (m.version <= current) continue;
    if (m.version !== current + 1) {
      throw new Error(
        `migration versions must be sequential: expected ${current + 1}, got ${m.version}`,
      );
    }
    db.transaction(() => {
      m.up(db);
      db.setUserVersion(m.version); // ← 同一トランザクション
    });
    current = m.version;
  }
}

/** アプリの全マイグレーションを適用する(起動時に呼ぶ) */
export function migrate(db: SqlDatabase): void {
  runMigrations(db, migrations);
}

設計上の要点は3つです。

  1. runMigrations(db, list) が list を引数に取る。 これがないと「v3までしか当たっていないDB」をテストで作れません。migrate() は薄いラッパにしておく
  2. current > latest は例外で落とす。 新しいアプリを入れた後に古いバージョンへ戻した(TestFlightの旧ビルドに戻る等)ケースです。ここで黙って動かすと、知らないカラムを無視したまま書き込んでデータを壊します
  3. 連番検証を入れる。 ブランチを2本並行させると「v8が2つある」が普通に起きます。起動時に落ちれば気付けます

個々のマイグレーションはSQL文字列1つ

v5(ジャンルと優先度の追加)はこれで全部です。

src/db/migrations/005_genre_priority.ts
export const SCHEMA_V5 = `
CREATE TABLE genres (
  id          TEXT PRIMARY KEY,
  name        TEXT NOT NULL,
  sort_order  INTEGER NOT NULL DEFAULT 0,
  created_at  INTEGER NOT NULL,
  updated_at  INTEGER NOT NULL,
  deleted_at  INTEGER
);
ALTER TABLE quests ADD COLUMN priority INTEGER NOT NULL DEFAULT 2;
ALTER TABLE quests ADD COLUMN genre_id TEXT REFERENCES genres(id);
`;

NOT NULL DEFAULT を付ける列と、nullableにする列の線引き

既定値が決まる列にだけ NOT NULL + DEFAULT を付けます。 上の priority のように既定値を書けば既存行がその値で自動的に埋まるので、アプリ側に「古い行はnullかもしれない」分岐が生えません。

逆に、外部キーや後から任意入力になる列は素直に nullable にします。SQLite は ALTER TABLE ... ADD COLUMNNOT NULL を使うなら非NULLの DEFAULT が必須で、genre_id のようなFK列には付けようがないからです。

このアプリの ADD COLUMN は11本ありますが、うち4本(repeat_every_days / folder_id / genre_id / completed_on)は nullable のままです。nullable にした列は、

  • 型定義に string | null を明示する
  • 既定値へ畳む場所をクエリ関数の行マッパー1箇所に固定する

これを守ると、9世代積んでも画面側のコードに null チェックが漏れ出しません。

データのバックフィルも同じ枠でやる

「追加のみ」は既存行のUPDATEを禁じるものではありません。v6では、列を足したあと同じSQLで埋めています。

src/db/migrations/006_exp_completed_on.ts
export const SCHEMA_V6 = `
ALTER TABLE exp_events ADD COLUMN completed_on TEXT;
UPDATE exp_events SET completed_on = (
  SELECT c.completed_on FROM quest_completions c WHERE c.id = exp_events.ref_id
) WHERE source = 'quest';
`;

同一トランザクションなので、途中で落ちても「列だけあって中身が空」にはなりません。

昇格テストを書く

ここが本番です。**「旧バージョンのDBに残りを当てて、新規インストールと同じ形になるか」**をCIで見張っていれば、リリース後にスキーマ差分で事故ることはまずありません。

src/db/__tests__/migrations.test.ts
test('user_version 0 の空DBが最新バージョンまで昇格する', () => {
  const db = createEmptyDb();
  expect(db.getUserVersion()).toBe(0);

  migrate(db);

  expect(db.getUserVersion()).toBe(LATEST_VERSION);
  expect(tableNames(db)).toEqual([
    'ad_boosts', 'app_settings', 'exp_events', 'folders', 'genres',
    'player', 'quest_completions', 'quests', 'skill_uses',
  ]); // 到達スキーマを固定する
});

test('v1時代のクエスト行が最新まで昇格しても保全され、新カラムはデフォルト値になる', () => {
  const db = createEmptyDb();
  runMigrations(db, [migrations[0]]); // 旧アプリ相当: v1のみ適用

  // v1スキーマ(repeat_every_days / due_has_time なし)でデータを投入
  db.run(
    `INSERT INTO quests (id, title, difficulty, due_at, repeat_type, repeat_days,
                         sort_order, created_at, updated_at)
     VALUES ('q-v1', 'v1時代のクエスト', 2, 5000, 'weekly', 5, 0, 1000, 1000)`,
  );

  runMigrations(db, migrations); // 新アプリ相当: 最新まで適用

  const row = db.getFirst<{
    title: string; due_at: number; due_has_time: number; repeat_every_days: number | null;
  }>("SELECT title, due_at, due_has_time, repeat_every_days FROM quests WHERE id = 'q-v1'");

  expect(row?.title).toBe('v1時代のクエスト');
  expect(row?.due_at).toBe(5000);
  expect(row?.due_has_time).toBe(0);          // v3のデフォルト値
  expect(row?.repeat_every_days).toBeNull();  // v2はnullable
});

「INSERTしたときにはまだ存在しなかった列」を、昇格後に読んで検証しているのが要点です。runMigrations(db, migrations.slice(0, 6)) のように書けば、任意の世代のDBが作れます。

そして、入れておくと一番安心するのがこの2本です。

src/db/__tests__/migrations.test.ts
test('バージョンが飛んでいるとエラー', () => {
  const db = createEmptyDb();
  const skipped: Migration = { version: LATEST_VERSION + 2, up: () => undefined };
  expect(() => runMigrations(db, [...migrations, skipped])).toThrow(/sequential/);
});

test('マイグレーション失敗時はロールバックされ user_version も進まない', () => {
  const db = createEmptyDb();
  const broken: Migration = {
    version: LATEST_VERSION + 1,
    up: (d) => {
      d.execBatch('CREATE TABLE will_rollback (a INTEGER)');
      throw new Error('boom');
    },
  };
  runMigrations(db, migrations);

  expect(() => runMigrations(db, [...migrations, broken])).toThrow('boom');
  expect(db.getUserVersion()).toBe(LATEST_VERSION);       // 進んでいない
  expect(tableNames(db)).not.toContain('will_rollback');  // 消えている
});

後者は「トランザクションで括る」という設計判断そのものをテストにしたものです。ここが壊れると、ユーザーの端末は user_version だけ上がっていて中身が古い という復旧不能な状態になります。

最悪なのは「user_version だけ上がってSQLが失敗した」状態です。以降のマイグレーションは「もう適用済み」と判断してスキップされ、アプリは存在しない列を読みにいきます。トランザクションで括るのは必須です。

実行環境と同じSQLiteでテストする

テストは Node 上の SQLite で回しますが、外部キー制約が実効しているかは必ず確認しておきます。PRAGMA foreign_keys は接続ごとの設定で、デフォルトOFFの環境があるためです。

src/db/__tests__/migrations.test.ts
test('外部キー制約が実効する(存在しないquest_idの完了記録はINSERTできない)', () => {
  const db = createTestDb();
  expect(() =>
    db.run(
      `INSERT INTO quest_completions (id, quest_id, completed_on, completed_at, exp_gained)
       VALUES ('c-1', 'no-such-quest', '2026-07-16', 0, 10)`,
    ),
  ).toThrow(/FOREIGN KEY/i);
});

これが通らないなら、テストは「FKが効いていないDB」を検証していることになります。


③ 時刻は Date.now() で取らず、引数で注入する

ストリーク(連続達成日数)、期限判定、繰り返しの次回出現日、広告ブーストの残り時間……時間依存のロジックが山ほどありました。方針はひとつ、ロジック層から Date.now() を全部追い出す

// ✕ テストのたびにシステム時刻をモックする羽目になる
export function startAdBoost(db: SqlDatabase): StartAdBoostResult

// ○ 呼び出し側が時刻を決める
export function startAdBoost(db: SqlDatabase, todayStr: string, now: number): StartAdBoostResult
export function recalcPlayer(db: SqlDatabase, today: string, now: number): PlayerRow
export function createQuest(db: SqlDatabase, input: CreateQuestInput, now: number): QuestRow

Date.now() を呼ぶのは画面コンポーネントの入口と、一部のサービス層(課金キャッシュの有効期限判定など、外部SDKの応答時刻をそのまま扱う場所)だけ。src/gamesrc/db からは完全に追い出しました。

実際、grep して残るのはコメント1件だけです。

$ grep -rn "Date.now()" src/game src/db --include="*.ts" | grep -v __tests__
src/game/stats.ts:5: * (Date.now()を内部で呼ばない — クエリ関数と同じテスト容易性の流儀)。

日付境界は「文字列」で扱う

日時は unix ミリ秒(INTEGER)で統一していますが、日付境界を扱う関数だけは端末ローカル日付文字列 'YYYY-MM-DD' を引数に取ります

src/game/streak.ts
/**
 * - 今日完了済みなら「…前々日・前日・今日」と連続する日数
 * - 今日未完了でも、昨日まで連続していればその日数を維持(今日はまだチャンスがあるため)
 * - 昨日も今日も完了がなければ 0(ストリーク途切れ)
 */
export function calcStreak(completedDates: Iterable<string>, today: string): number {
  const days = new Set(completedDates);
  if (days.size === 0) return 0;

  const anchor = days.has(today) ? today : addDays(today, -1);
  if (!days.has(anchor)) return 0;

  let streak = 0;
  let cursor = anchor;
  while (days.has(cursor)) {
    streak++;
    cursor = addDays(cursor, -1);
  }
  return streak;
}

この関数はDBもタイムゾーンも時計も知らない、ただの集合演算です。だからテストは文字列を並べるだけになります。

src/game/__tests__/streak.test.ts
const TODAY = '2026-07-16';

test('今日未完了でも昨日まで連続していれば維持 → 3', () => {
  expect(calcStreak(['2026-07-13', '2026-07-14', '2026-07-15'], TODAY)).toBe(3);
});

test('昨日も今日も完了なし → 0(途切れ)', () => {
  expect(calcStreak(['2026-07-13', '2026-07-14'], TODAY)).toBe(0);
});

test('途中に穴があると穴以前はカウントしない', () => {
  expect(calcStreak(['2026-07-12', '2026-07-14', '2026-07-15', '2026-07-16'], TODAY)).toBe(3);
});

test('同日重複(複数クエスト完了)は1日として数える', () => {
  expect(calcStreak(['2026-07-15', '2026-07-15', '2026-07-16', '2026-07-16'], TODAY)).toBe(2);
});

test('年跨ぎの連続', () => {
  expect(calcStreak(['2025-12-31', '2026-01-01'], '2026-01-01')).toBe(2);
});

test('未来の日付が混ざっていても今日から遡ってカウントする', () => {
  expect(calcStreak(['2026-07-16', '2026-07-20'], TODAY)).toBe(1);
});

「年をまたぐ」「今日サボったが昨日まで30日連続」「未来日付が混ざる」を、セットアップ0行で書けます。jest.useFakeTimers() は最後まで一度も使いませんでした。

日付ヘルパーの落とし穴2つ

落とし穴1: toISOString().slice(0, 10) を使わない

Date#toISOString()UTC を返します。JST(UTC+9)の午前0時〜午前9時は前日の日付になり、「深夜にタスクを完了したのにストリークが伸びない」という最悪のバグになります。ローカル日付は必ず自前で組み立てます。

src/game/dates.ts
/** Dateを端末ローカルの 'YYYY-MM-DD' に変換する */
export function localDateString(date: Date): string {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, '0');
  const d = String(date.getDate()).padStart(2, '0');
  return `${y}-${m}-${d}`;
}

落とし穴2: 日付の加減算は「UTC正午」を基準にする

'YYYY-MM-DD' に日数を足すとき、ローカルの0時を基準にするとサマータイム(DST)のある地域で1日ずれます。DSTの切り替わり日は23時間や25時間になるためです。UTCの正午を基準に86,400,000msを足せば、±1時間動いても日付は変わりません。

src/game/dates.ts
/**
 * 'YYYY-MM-DD' にdays日を加算する(負数で減算)。
 * タイムゾーンの影響を受けないようUTC正午を基準に計算する。
 */
export function addDays(dateStr: string, days: number): string {
  const [y, m, d] = parseDate(dateStr);
  const t = Date.UTC(y, m - 1, d, 12) + days * 86_400_000;
  const nd = new Date(t);
  const yy = nd.getUTCFullYear();
  const mm = String(nd.getUTCMonth() + 1).padStart(2, '0');
  const dd = String(nd.getUTCDate()).padStart(2, '0');
  return `${yy}-${mm}-${dd}`;
}

/** 「今日のクエスト」抽出の期限境界: 端末ローカルの翌日0時のunixミリ秒 */
export function startOfTomorrowMs(now: Date): number {
  return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime();
}

'YYYY-MM-DD'Date の変換をこの2関数に閉じ込めておくと、タイムゾーン起因のバグは実質ここだけを見ればよくなります。

副産物

時刻を引数にすると、バックアップ復元やデータ移行のときに「過去の時刻」でロジックを再実行できます。EXPを追記イベント(exp_events)で持ち、player.total_exp / level を再計算可能なキャッシュとして扱う設計と相性がよく、「復元後に再計算関数を流す」だけで整合が取れます。


④ サブスクを出すなら「アプリ説明文」にEULAリンクが要る(Guideline 3.1.2)

1回目の却下はこれでした。文面はほぼ定型です。

The submission offers auto-renewable subscriptions but does not include a functional link to the Terms of Use (EULA) in the app's metadata.

アプリ内のペイウォールには規約リンクを置いていました。足りなかったのはストア側のメタデータです。

結論から言うと、カスタムEULAか標準EULAかにかかわらず、App Description(アプリ説明文)にEULAのリンクを直接書くのが確実です。

  • App Store Connect の App Information には License Agreement のセクションがありますが、標準EULAを選んだ場合、URLの入力欄はありません(登録できるのはカスタムEULAの本文だけ)
  • カスタムEULAをASCに登録済みでも「メタデータに機能するリンクがない」で弾かれ続け、App Description に直接リンクを貼って初めて通った、という報告もあります(Max Mannstein のブログ

根拠はガイドライン 3.1.2(c) の本文ではなく、そこが参照する Apple Developer Program License Agreement の Schedule 2 と、Appleのサブスクリプション解説ページのこの一文です。

Please note that your app and App Store metadata must include links to your Terms of Use and Privacy Policy.
https://developer.apple.com/app-store/subscriptions/

条件が付いていません。アプリ内とストアメタデータの両方が要る、と読むのが安全です。

標準EULAのURLは https://www.apple.com/legal/internet-services/itunes/dev/stdeula/。説明文の末尾にこう足しました。

利用規約(EULA): https://www.apple.com/legal/internet-services/itunes/dev/stdeula/
プライバシーポリシー: https://(自分のURL)

同じページはサインアップ画面(=ペイウォール)側の要件も定めていて、

  • サブスクリプション名と期間
  • その期間に提供される内容
  • 更新価格を、はっきり目立つ形で・現地通貨で
  • 既存加入者がサインインまたは購入を復元できる導線

が必須です。ここも合わせて埋めておくと二度手間になりません。

この修正はメタデータのみ・再ビルド不要でした。リジェクトの中では一番安いやつです。


おまけ: AIエージェントと組むときに効いた制約

ここだけポエムです。飛ばして構いません。

AIエージェント開発で効いたのは、モデルの賢さではなく制約の設計でした。やったのは3つ。

1. 仕様書を「正」にして、コードより先に書く。 docs/ に5本(要件・画面設計・データ設計・環境構築・デザイントークン)を置き、常設指示ファイルにこう書きました。

実装判断に迷ったら必ず docs/ を参照する。この5文書と矛盾するコードを書かない。矛盾を見つけたら実装せずに指摘する。

大事なのは最後の一文です。エージェントは黙って辻褄を合わせにいくので、「食い違いは実装せず報告」を明文化すると仕様のバグが実装前に浮きます。実際「この繰り返し仕様だと隔週の起点が決まらない」といった指摘が何度か返ってきました。

2. マイルストーンを1つずつ、実機確認を挟む。 M1(初期化)→ M2(DB層)→ … → M8(仕上げ)と8段階に割り、1マイルストーン = 1依頼に固定。加えて「マイルストーンをまたぐ先回り実装をしない」と明記しました。これがないと、動くけれど誰もレビューしていないコードが増えます。

3. 実装役とは別セッションのエージェントにレビューさせる。 書いた本人に「合ってる?」と聞くと、だいたい「合っています」と返ってきます。仕様書とdiffだけ渡して、観点は毎回同じ3つ。

  1. docs/ の仕様と矛盾していないか
  2. 規約(デザイントークン直書き禁止・i18nハードコード禁止・any 禁止)を破っていないか
  3. テストが「実装の写経」になっていないか

3番目が特に効きます。同一セッションのエージェントは自分の実装をなぞるテストを書きがちなので、「その関数が壊れてもこのテストは落ちない」を別の目で指摘させる。人間のレビューの代替ではなく、人間が見る前のノイズ除去として機能しました。


チェックリスト

ATT(広告を入れる iOS アプリ)

  • AppState.currentState === 'active' を確認してから要求している
  • undetermined を「拒否」と誤認せず、間隔を空けて再試行している
  • 待機後に active から落ちていないか再確認してから要求している
  • 他の権限ダイアログと同時に出していない
  • NSUserTrackingUsageDescription がある
  • 設定画面に手動導線がある(回答済みなら設定アプリを開くフォールバック付き)
  • 審査ノートに操作手順を書き、実機の画面収録を添付した
  • 本番の広告ユニットIDで開発ビルドを回していない(__DEV__ でテストIDに強制切替)
  • EEA配信なら同意フォーム(UMP)を実装した/していないなら配信国から除外した

ローカルDBを持つアプリ

  • マイグレーションは追加のみ(CREATE TABLE / ADD COLUMN
  • 既定値が決まる追加列は NOT NULL DEFAULT 付き。FKや任意入力の列は nullable にし、型に | null を出して既定値へ畳む場所を1箇所に固定した
  • スキーマ適用と user_version 更新が同一トランザクション
  • 連番検証が入っている(ブランチ合流時の番号衝突を起動時に検知)
  • 旧バージョンからの昇格テストがCIで動く
  • 失敗時にロールバックされることをテストしている
  • アプリより新しいスキーマを踏んだら安全に落ちる(勝手に触らない)
  • 時間依存のロジックは now / today を引数で受け取る
  • toISOString() でローカル日付を作っていない
  • 日付の加減算がDSTで壊れない

サブスクを出す

  • アプリ説明文にEULAリンクとプライバシーポリシーURLがある
  • ペイウォールに名称・期間・更新価格・復元導線・規約/ポリシーのリンクが全部ある

おわりに

23日で出せたのはAIが速いからというより、「実装より先に仕様を書く」「一度に一区画しか進めない」という古い規律をAI相手にも適用したからだと思っています。エージェントは指示の空白を勝手に埋めるので、空白を減らす作業がそのまま生産性になりました。

審査の却下は2回とも、コードの質とは無関係な「知っているかどうか」の問題でした。この記事がその分の時間を誰かから削れたなら嬉しいです。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?